From 63bdd7dd7470e115fda515614da60b23aec838a7 Mon Sep 17 00:00:00 2001 From: danamir Date: Sun, 9 Aug 2026 00:54:45 +0300 Subject: [PATCH] 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. --- src/Client/Features/Teacher/Tests/State.fs | 127 ++++++++++++++------- src/Client/Features/Teacher/Tests/Types.fs | 68 +++++------ src/Client/Features/Teacher/Tests/View.fs | 109 ++++++++++++------ 3 files changed, 188 insertions(+), 116 deletions(-) diff --git a/src/Client/Features/Teacher/Tests/State.fs b/src/Client/Features/Teacher/Tests/State.fs index 81d69f0..06039f3 100644 --- a/src/Client/Features/Teacher/Tests/State.fs +++ b/src/Client/Features/Teacher/Tests/State.fs @@ -175,34 +175,74 @@ let update (token: string option) (msg: Msg) (model: Model) : Model * Cmd = { model with QuizForm = { model.QuizForm with ShuffleQuestions = value } }, Cmd.none | SetQuizShuffleAnswers value -> { model with QuizForm = { model.QuizForm with ShuffleAnswers = value } }, Cmd.none - | SelectPickerTopic topicId -> - let cmd = - Cmd.OfAsync.either - (Api.listQuestionsInTopic token) - topicId - (function - | Ok questions -> PickerQuestionsLoaded questions - | Error err -> PickerQuestionsLoadFailed err) - (fun ex -> PickerQuestionsLoadFailed ex.Message) + | ToggleTopicInPicker topicId -> + let form = model.QuizForm - let existingPoolCount = - model.QuizForm.Sources - |> List.tryPick (function - | PoolDraft(tid, count) when tid = topicId -> Some count - | _ -> None) + 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 - PickerTopicId = Some topicId - PickerLoading = true - PickerQuestions = [] - PoolCountText = existingPoolCount |> Option.map string |> Option.defaultValue "" } }, - cmd - | PickerQuestionsLoaded questions -> - { model with QuizForm = { model.QuizForm with PickerQuestions = questions; PickerLoading = false } }, Cmd.none - | PickerQuestionsLoadFailed err -> - { model with QuizForm = { model.QuizForm with PickerLoading = false; Error = Some err } }, Cmd.none + 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 @@ -216,7 +256,7 @@ let update (token: string option) (msg: Msg) (model: Model) : Model * Cmd = form.Sources @ [ FixedDraft questionId ] { model with QuizForm = { form with Sources = next } }, Cmd.none - | SelectAllInTopic -> + | SelectAllInTopic topicId -> let form = model.QuizForm let alreadySelected = @@ -228,31 +268,32 @@ let update (token: string option) (msg: Msg) (model: Model) : Model * Cmd = 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 text -> { model with QuizForm = { model.QuizForm with PoolCountText = text } }, Cmd.none - | AddRandomPool -> + | 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 form.PickerTopicId with - | None -> model, Cmd.none - | Some topicId -> - match System.Int32.TryParse form.PoolCountText with - | true, count when count > 0 -> - let withoutOldPool = - form.Sources - |> List.filter (function - | PoolDraft(tid, _) -> tid <> topicId - | _ -> true) + 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 + { 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 @@ -262,9 +303,7 @@ let update (token: string option) (msg: Msg) (model: Model) : Model * Cmd = | PoolDraft(tid, _) -> tid <> topicId | _ -> true) - let poolCountText = if form.PickerTopicId = Some topicId then "" else form.PoolCountText - - { model with QuizForm = { form with Sources = next; PoolCountText = poolCountText } }, Cmd.none + { 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 diff --git a/src/Client/Features/Teacher/Tests/Types.fs b/src/Client/Features/Teacher/Tests/Types.fs index da0d57d..757eba6 100644 --- a/src/Client/Features/Teacher/Tests/Types.fs +++ b/src/Client/Features/Teacher/Tests/Types.fs @@ -21,16 +21,19 @@ type QuizForm = PassingScoreText: string // empty = None ShuffleQuestions: bool ShuffleAnswers: bool - /// Accumulates across topic switches in the picker below — membership - /// alone decides the checkbox state, regardless of which topic's + /// Accumulates across topics selected in the picker below — membership + /// alone decides the checkbox state, regardless of which topics' /// questions are currently displayed. Sources: QuizSourceDraft list - PickerTopicId: TopicId option + /// Topics currently expanded in the picker — a test can draw questions + /// from any number of them at once. + PickerTopicIds: Set + /// Union of questions loaded for every topic in `PickerTopicIds`. PickerQuestions: QuestionSummary list - PickerLoading: bool - /// Draft text for "N случайных вопросов" of the topic currently open - /// in the picker — reset whenever the picker's topic changes. - PoolCountText: string + /// Topics whose questions are still being fetched. + PickerLoadingTopics: Set + /// Draft text for "N случайных вопросов", one per expanded topic. + PoolCountTexts: Map Error: string option IsSubmitting: bool } @@ -43,34 +46,31 @@ let emptyQuizForm = ShuffleQuestions = false ShuffleAnswers = false Sources = [] - PickerTopicId = None + PickerTopicIds = Set.empty PickerQuestions = [] - PickerLoading = false - PoolCountText = "" + PickerLoadingTopics = Set.empty + PoolCountTexts = Map.empty Error = None IsSubmitting = false } -/// Ids of `form.PickerQuestions` (the topic currently open in the picker) -/// that are individually fixed-selected — used to enforce "fixed selection -/// XOR random pool" per topic, one topic at a time. -let fixedIdsInCurrentTopic (form: QuizForm) : QuestionId list = - let topicIds = form.PickerQuestions |> List.map (fun q -> q.Id) |> Set.ofList +/// Loaded questions belonging to one topic. +let questionsInTopic (form: QuizForm) (topicId: TopicId) : QuestionSummary list = + form.PickerQuestions |> List.filter (fun q -> q.TopicId = topicId) +/// Whether a question is individually fixed-selected — used to enforce +/// "fixed selection XOR random pool" per topic. +let isFixedSelected (form: QuizForm) (questionId: QuestionId) : bool = form.Sources - |> List.choose (function - | FixedDraft qid when topicIds.Contains qid -> Some qid - | _ -> None) + |> List.exists (function + | FixedDraft qid -> qid = questionId + | _ -> false) -/// The random-pool count configured for the topic currently open in the -/// picker, if any. -let poolCountForCurrentTopic (form: QuizForm) : int option = - match form.PickerTopicId with - | None -> None - | Some topicId -> - form.Sources - |> List.tryPick (function - | PoolDraft(tid, count) when tid = topicId -> Some count - | _ -> None) +/// The random-pool count configured for a given topic, if any. +let poolCountForTopic (form: QuizForm) (topicId: TopicId) : int option = + form.Sources + |> List.tryPick (function + | PoolDraft(tid, count) when tid = topicId -> Some count + | _ -> None) /// Total number of questions the quiz will actually have: one per fixed /// selection, plus each pool rule's `Count`. @@ -162,13 +162,13 @@ type Msg = | SetQuizPassingScoreText of string | SetQuizShuffleQuestions of bool | SetQuizShuffleAnswers of bool - | SelectPickerTopic of TopicId - | PickerQuestionsLoaded of QuestionSummary list - | PickerQuestionsLoadFailed of string + | ToggleTopicInPicker of TopicId + | PickerQuestionsLoaded of TopicId * QuestionSummary list + | PickerQuestionsLoadFailed of TopicId * string | ToggleQuestionPick of QuestionId - | SelectAllInTopic - | SetPoolCountText of string - | AddRandomPool + | SelectAllInTopic of TopicId + | SetPoolCountText of TopicId * string + | AddRandomPool of TopicId | RemoveRandomPool of TopicId | SubmitQuizForm | QuizSaved of QuizAdminSummary diff --git a/src/Client/Features/Teacher/Tests/View.fs b/src/Client/Features/Teacher/Tests/View.fs index d578f22..c5ff23c 100644 --- a/src/Client/Features/Teacher/Tests/View.fs +++ b/src/Client/Features/Teacher/Tests/View.fs @@ -203,16 +203,16 @@ let private resultsView (model: Model) dispatch = ] ] -/// Summary of every configured random-pool rule, across all topics — needed -/// because the picker below only shows one topic's questions at a time, so -/// without this list, switching the topic dropdown away would make an -/// already-configured pool rule invisible. +/// Summary of configured random-pool rules whose topic isn't currently +/// expanded in the picker below (e.g. right after opening an existing quiz +/// for editing) — without this, such a pool rule would be invisible until +/// its topic checkbox is ticked again. let private poolRulesSummary (model: Model) dispatch = let pools = model.QuizForm.Sources |> List.choose (function - | PoolDraft(topicId, count) -> Some(topicId, count) - | FixedDraft _ -> None) + | PoolDraft(topicId, count) when not (model.QuizForm.PickerTopicIds.Contains topicId) -> Some(topicId, count) + | _ -> None) if pools.IsEmpty then Html.none @@ -241,33 +241,33 @@ let private poolRulesSummary (model: Model) dispatch = ] ] -let private questionPicker (model: Model) dispatch = +/// One expanded topic's slice of the picker: either its fixed-question +/// checkboxes, or its random-pool config, plus the "N нет вопросов"/loading +/// states. +let private topicPickerSection (model: Model) dispatch topicId = let form = model.QuizForm - let poolForTopic = poolCountForCurrentTopic form - let fixedInTopic = fixedIdsInCurrentTopic form + let topicName = + model.Topics + |> List.tryFind (fun t -> t.Id = topicId) + |> Option.map (fun t -> t.Name) + |> Option.defaultValue "?" + + let topicQuestions = questionsInTopic form topicId + let poolCount = poolCountForTopic form topicId + let fixedInTopic = topicQuestions |> List.map (fun q -> q.Id) |> List.filter (isFixedSelected form) + let poolCountText = form.PoolCountTexts |> Map.tryFind topicId |> Option.defaultValue "" Html.div [ - prop.className "question-picker" + prop.key (string topicId) + prop.className "topic-picker-section" prop.children [ - Html.label [ prop.text "Тема" ] - Html.select [ - prop.value (form.PickerTopicId |> Option.map string |> Option.defaultValue "") - prop.onChange (fun (v: string) -> - model.Topics - |> List.tryFind (fun t -> string t.Id = v) - |> Option.iter (fun t -> dispatch (SelectPickerTopic t.Id))) - prop.children [ - Html.option [ prop.value ""; prop.text "— выберите тему —" ] - for topic in model.Topics do - Html.option [ prop.key (string topic.Id); prop.value (string topic.Id); prop.text topic.Name ] - ] - ] - if form.PickerLoading then + Html.h4 topicName + if form.PickerLoadingTopics.Contains topicId then Html.p "Загрузка вопросов…" - elif form.PickerTopicId.IsSome && form.PickerQuestions.IsEmpty then + elif topicQuestions.IsEmpty then Html.p "В этой теме нет вопросов" - elif form.PickerTopicId.IsSome then - match poolForTopic with + else + match poolCount with | Some count -> Html.div [ prop.className "pool-picker" @@ -280,18 +280,17 @@ let private questionPicker (model: Model) dispatch = Html.input [ prop.type'.text prop.className "pool-count-input" - prop.value form.PoolCountText - prop.onChange (SetPoolCountText >> dispatch) + prop.value poolCountText + prop.onChange (fun v -> dispatch (SetPoolCountText(topicId, v))) ] Html.button [ prop.type'.button - prop.onClick (fun _ -> dispatch AddRandomPool) + prop.onClick (fun _ -> dispatch (AddRandomPool topicId)) prop.text "Обновить количество" ] Html.button [ prop.type'.button - prop.onClick (fun _ -> - form.PickerTopicId |> Option.iter (RemoveRandomPool >> dispatch)) + prop.onClick (fun _ -> dispatch (RemoveRandomPool topicId)) prop.text "Убрать случайный набор, выбирать вручную" ] ] @@ -301,7 +300,7 @@ let private questionPicker (model: Model) dispatch = prop.children [ Html.div [ prop.children [ - for q in form.PickerQuestions -> + for q in topicQuestions -> Html.label [ prop.key (string q.Id) prop.className "student-row" @@ -318,7 +317,7 @@ let private questionPicker (model: Model) dispatch = ] Html.button [ prop.type'.button - prop.onClick (fun _ -> dispatch SelectAllInTopic) + prop.onClick (fun _ -> dispatch (SelectAllInTopic topicId)) prop.text "Выбрать все вопросы темы" ] Html.p "или задать случайный набор вместо ручного выбора:" @@ -326,13 +325,13 @@ let private questionPicker (model: Model) dispatch = prop.type'.text prop.className "pool-count-input" prop.placeholder "Число вопросов" - prop.value form.PoolCountText - prop.onChange (SetPoolCountText >> dispatch) + prop.value poolCountText + prop.onChange (fun v -> dispatch (SetPoolCountText(topicId, v))) ] Html.button [ prop.type'.button prop.disabled (not fixedInTopic.IsEmpty) - prop.onClick (fun _ -> dispatch AddRandomPool) + prop.onClick (fun _ -> dispatch (AddRandomPool topicId)) prop.text "Добавить случайный набор из этой темы" ] if not fixedInTopic.IsEmpty then @@ -342,8 +341,42 @@ let private questionPicker (model: Model) dispatch = ] ] ] + ] + ] + +let private questionPicker (model: Model) dispatch = + let form = model.QuizForm + + Html.div [ + prop.className "question-picker" + prop.children [ + Html.label [ prop.text "Темы (можно выбрать несколько)" ] + Html.div [ + prop.className "topic-checkbox-list" + prop.children [ + for topic in model.Topics -> + Html.label [ + prop.key (string topic.Id) + prop.className "student-row" + prop.children [ + Html.input [ + prop.type'.checkbox + prop.isChecked (form.PickerTopicIds.Contains topic.Id) + prop.onChange (fun (_: bool) -> dispatch (ToggleTopicInPicker topic.Id)) + ] + Html.text topic.Name + ] + ] + ] + ] + if form.PickerTopicIds.IsEmpty then + Html.p [ + prop.className "hint" + prop.text "Выберите одну или несколько тем, из которых будут вопросы теста" + ] else - Html.none + for topicId in form.PickerTopicIds |> Set.toList do + topicPickerSection model dispatch topicId poolRulesSummary model dispatch Html.span [ prop.className "tag-mono"