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.
This commit is contained in:
danamir
2026-08-09 00:54:45 +03:00
parent b5921292db
commit 63bdd7dd74
3 changed files with 188 additions and 116 deletions

View File

@@ -175,34 +175,74 @@ let update (token: string option) (msg: Msg) (model: Model) : Model * Cmd<Msg> =
{ model with QuizForm = { model.QuizForm with ShuffleQuestions = value } }, Cmd.none { model with QuizForm = { model.QuizForm with ShuffleQuestions = value } }, Cmd.none
| SetQuizShuffleAnswers value -> | SetQuizShuffleAnswers value ->
{ model with QuizForm = { model.QuizForm with ShuffleAnswers = value } }, Cmd.none { model with QuizForm = { model.QuizForm with ShuffleAnswers = value } }, Cmd.none
| SelectPickerTopic topicId -> | ToggleTopicInPicker topicId ->
let cmd = let form = model.QuizForm
Cmd.OfAsync.either
(Api.listQuestionsInTopic token)
topicId
(function
| Ok questions -> PickerQuestionsLoaded questions
| Error err -> PickerQuestionsLoadFailed err)
(fun ex -> PickerQuestionsLoadFailed ex.Message)
let existingPoolCount = if form.PickerTopicIds.Contains topicId then
model.QuizForm.Sources // Deselecting a topic drops it from the quiz entirely its
|> List.tryPick (function // fixed picks and pool rule no longer apply.
| PoolDraft(tid, count) when tid = topicId -> Some count let belongsToTopic qid =
| _ -> None) 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 { model with
QuizForm = QuizForm =
{ model.QuizForm with { model.QuizForm with
PickerTopicId = Some topicId PickerQuestions = model.QuizForm.PickerQuestions @ questions
PickerLoading = true PickerLoadingTopics = model.QuizForm.PickerLoadingTopics.Remove topicId } },
PickerQuestions = [] Cmd.none
PoolCountText = existingPoolCount |> Option.map string |> Option.defaultValue "" } }, | PickerQuestionsLoadFailed(topicId, err) ->
cmd { model with
| PickerQuestionsLoaded questions -> QuizForm =
{ model with QuizForm = { model.QuizForm with PickerQuestions = questions; PickerLoading = false } }, Cmd.none { model.QuizForm with
| PickerQuestionsLoadFailed err -> PickerLoadingTopics = model.QuizForm.PickerLoadingTopics.Remove topicId
{ model with QuizForm = { model.QuizForm with PickerLoading = false; Error = Some err } }, Cmd.none Error = Some err } },
Cmd.none
| ToggleQuestionPick questionId -> | ToggleQuestionPick questionId ->
let form = model.QuizForm let form = model.QuizForm
@@ -216,7 +256,7 @@ let update (token: string option) (msg: Msg) (model: Model) : Model * Cmd<Msg> =
form.Sources @ [ FixedDraft questionId ] form.Sources @ [ FixedDraft questionId ]
{ model with QuizForm = { form with Sources = next } }, Cmd.none { model with QuizForm = { form with Sources = next } }, Cmd.none
| SelectAllInTopic -> | SelectAllInTopic topicId ->
let form = model.QuizForm let form = model.QuizForm
let alreadySelected = let alreadySelected =
@@ -228,31 +268,32 @@ let update (token: string option) (msg: Msg) (model: Model) : Model * Cmd<Msg> =
let toAdd = let toAdd =
form.PickerQuestions form.PickerQuestions
|> List.filter (fun q -> q.TopicId = topicId)
|> List.map (fun q -> q.Id) |> List.map (fun q -> q.Id)
|> List.filter (alreadySelected.Contains >> not) |> List.filter (alreadySelected.Contains >> not)
|> List.map FixedDraft |> List.map FixedDraft
{ model with QuizForm = { form with Sources = form.Sources @ toAdd } }, Cmd.none { model with QuizForm = { form with Sources = form.Sources @ toAdd } }, Cmd.none
| SetPoolCountText text -> { model with QuizForm = { model.QuizForm with PoolCountText = text } }, Cmd.none | SetPoolCountText(topicId, text) ->
| AddRandomPool -> { model with QuizForm = { model.QuizForm with PoolCountTexts = model.QuizForm.PoolCountTexts |> Map.add topicId text } },
Cmd.none
| AddRandomPool topicId ->
let form = model.QuizForm let form = model.QuizForm
let text = form.PoolCountTexts |> Map.tryFind topicId |> Option.defaultValue ""
match form.PickerTopicId with match System.Int32.TryParse text with
| None -> model, Cmd.none | true, count when count > 0 ->
| Some topicId -> let withoutOldPool =
match System.Int32.TryParse form.PoolCountText with form.Sources
| true, count when count > 0 -> |> List.filter (function
let withoutOldPool = | PoolDraft(tid, _) -> tid <> topicId
form.Sources | _ -> true)
|> List.filter (function
| PoolDraft(tid, _) -> tid <> topicId
| _ -> true)
{ model with QuizForm = { form with Sources = withoutOldPool @ [ PoolDraft(topicId, count) ]; Error = None } }, { model with QuizForm = { form with Sources = withoutOldPool @ [ PoolDraft(topicId, count) ]; Error = None } },
Cmd.none Cmd.none
| _ -> | _ ->
{ model with QuizForm = { form with Error = Some "Количество случайных вопросов должно быть положительным числом" } }, { model with QuizForm = { form with Error = Some "Количество случайных вопросов должно быть положительным числом" } },
Cmd.none Cmd.none
| RemoveRandomPool topicId -> | RemoveRandomPool topicId ->
let form = model.QuizForm let form = model.QuizForm
@@ -262,9 +303,7 @@ let update (token: string option) (msg: Msg) (model: Model) : Model * Cmd<Msg> =
| PoolDraft(tid, _) -> tid <> topicId | PoolDraft(tid, _) -> tid <> topicId
| _ -> true) | _ -> true)
let poolCountText = if form.PickerTopicId = Some topicId then "" else form.PoolCountText { model with QuizForm = { form with Sources = next; PoolCountTexts = form.PoolCountTexts.Remove topicId } }, Cmd.none
{ model with QuizForm = { form with Sources = next; PoolCountText = poolCountText } }, Cmd.none
| SubmitQuizForm -> | SubmitQuizForm ->
match buildQuizFields model.QuizForm with match buildQuizFields model.QuizForm with
| Error err -> { model with QuizForm = { model.QuizForm with Error = Some err } }, Cmd.none | Error err -> { model with QuizForm = { model.QuizForm with Error = Some err } }, Cmd.none

View File

@@ -21,16 +21,19 @@ type QuizForm =
PassingScoreText: string // empty = None PassingScoreText: string // empty = None
ShuffleQuestions: bool ShuffleQuestions: bool
ShuffleAnswers: bool ShuffleAnswers: bool
/// Accumulates across topic switches in the picker below membership /// Accumulates across topics selected in the picker below membership
/// alone decides the checkbox state, regardless of which topic's /// alone decides the checkbox state, regardless of which topics'
/// questions are currently displayed. /// questions are currently displayed.
Sources: QuizSourceDraft list 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<TopicId>
/// Union of questions loaded for every topic in `PickerTopicIds`.
PickerQuestions: QuestionSummary list PickerQuestions: QuestionSummary list
PickerLoading: bool /// Topics whose questions are still being fetched.
/// Draft text for "N случайных вопросов" of the topic currently open PickerLoadingTopics: Set<TopicId>
/// in the picker reset whenever the picker's topic changes. /// Draft text for "N случайных вопросов", one per expanded topic.
PoolCountText: string PoolCountTexts: Map<TopicId, string>
Error: string option Error: string option
IsSubmitting: bool } IsSubmitting: bool }
@@ -43,34 +46,31 @@ let emptyQuizForm =
ShuffleQuestions = false ShuffleQuestions = false
ShuffleAnswers = false ShuffleAnswers = false
Sources = [] Sources = []
PickerTopicId = None PickerTopicIds = Set.empty
PickerQuestions = [] PickerQuestions = []
PickerLoading = false PickerLoadingTopics = Set.empty
PoolCountText = "" PoolCountTexts = Map.empty
Error = None Error = None
IsSubmitting = false } IsSubmitting = false }
/// Ids of `form.PickerQuestions` (the topic currently open in the picker) /// Loaded questions belonging to one topic.
/// that are individually fixed-selected used to enforce "fixed selection let questionsInTopic (form: QuizForm) (topicId: TopicId) : QuestionSummary list =
/// XOR random pool" per topic, one topic at a time. form.PickerQuestions |> List.filter (fun q -> q.TopicId = topicId)
let fixedIdsInCurrentTopic (form: QuizForm) : QuestionId list =
let topicIds = form.PickerQuestions |> List.map (fun q -> q.Id) |> Set.ofList
/// 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 form.Sources
|> List.choose (function |> List.exists (function
| FixedDraft qid when topicIds.Contains qid -> Some qid | FixedDraft qid -> qid = questionId
| _ -> None) | _ -> false)
/// The random-pool count configured for the topic currently open in the /// The random-pool count configured for a given topic, if any.
/// picker, if any. let poolCountForTopic (form: QuizForm) (topicId: TopicId) : int option =
let poolCountForCurrentTopic (form: QuizForm) : int option = form.Sources
match form.PickerTopicId with |> List.tryPick (function
| None -> None | PoolDraft(tid, count) when tid = topicId -> Some count
| Some topicId -> | _ -> None)
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 /// Total number of questions the quiz will actually have: one per fixed
/// selection, plus each pool rule's `Count`. /// selection, plus each pool rule's `Count`.
@@ -162,13 +162,13 @@ type Msg =
| SetQuizPassingScoreText of string | SetQuizPassingScoreText of string
| SetQuizShuffleQuestions of bool | SetQuizShuffleQuestions of bool
| SetQuizShuffleAnswers of bool | SetQuizShuffleAnswers of bool
| SelectPickerTopic of TopicId | ToggleTopicInPicker of TopicId
| PickerQuestionsLoaded of QuestionSummary list | PickerQuestionsLoaded of TopicId * QuestionSummary list
| PickerQuestionsLoadFailed of string | PickerQuestionsLoadFailed of TopicId * string
| ToggleQuestionPick of QuestionId | ToggleQuestionPick of QuestionId
| SelectAllInTopic | SelectAllInTopic of TopicId
| SetPoolCountText of string | SetPoolCountText of TopicId * string
| AddRandomPool | AddRandomPool of TopicId
| RemoveRandomPool of TopicId | RemoveRandomPool of TopicId
| SubmitQuizForm | SubmitQuizForm
| QuizSaved of QuizAdminSummary | QuizSaved of QuizAdminSummary

View File

@@ -203,16 +203,16 @@ let private resultsView (model: Model) dispatch =
] ]
] ]
/// Summary of every configured random-pool rule, across all topics needed /// Summary of configured random-pool rules whose topic isn't currently
/// because the picker below only shows one topic's questions at a time, so /// expanded in the picker below (e.g. right after opening an existing quiz
/// without this list, switching the topic dropdown away would make an /// for editing) without this, such a pool rule would be invisible until
/// already-configured pool rule invisible. /// its topic checkbox is ticked again.
let private poolRulesSummary (model: Model) dispatch = let private poolRulesSummary (model: Model) dispatch =
let pools = let pools =
model.QuizForm.Sources model.QuizForm.Sources
|> List.choose (function |> List.choose (function
| PoolDraft(topicId, count) -> Some(topicId, count) | PoolDraft(topicId, count) when not (model.QuizForm.PickerTopicIds.Contains topicId) -> Some(topicId, count)
| FixedDraft _ -> None) | _ -> None)
if pools.IsEmpty then if pools.IsEmpty then
Html.none 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 form = model.QuizForm
let poolForTopic = poolCountForCurrentTopic form let topicName =
let fixedInTopic = fixedIdsInCurrentTopic form 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 [ Html.div [
prop.className "question-picker" prop.key (string topicId)
prop.className "topic-picker-section"
prop.children [ prop.children [
Html.label [ prop.text "Тема" ] Html.h4 topicName
Html.select [ if form.PickerLoadingTopics.Contains topicId then
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.p "Загрузка вопросов…" Html.p "Загрузка вопросов…"
elif form.PickerTopicId.IsSome && form.PickerQuestions.IsEmpty then elif topicQuestions.IsEmpty then
Html.p "В этой теме нет вопросов" Html.p "В этой теме нет вопросов"
elif form.PickerTopicId.IsSome then else
match poolForTopic with match poolCount with
| Some count -> | Some count ->
Html.div [ Html.div [
prop.className "pool-picker" prop.className "pool-picker"
@@ -280,18 +280,17 @@ let private questionPicker (model: Model) dispatch =
Html.input [ Html.input [
prop.type'.text prop.type'.text
prop.className "pool-count-input" prop.className "pool-count-input"
prop.value form.PoolCountText prop.value poolCountText
prop.onChange (SetPoolCountText >> dispatch) prop.onChange (fun v -> dispatch (SetPoolCountText(topicId, v)))
] ]
Html.button [ Html.button [
prop.type'.button prop.type'.button
prop.onClick (fun _ -> dispatch AddRandomPool) prop.onClick (fun _ -> dispatch (AddRandomPool topicId))
prop.text "Обновить количество" prop.text "Обновить количество"
] ]
Html.button [ Html.button [
prop.type'.button prop.type'.button
prop.onClick (fun _ -> prop.onClick (fun _ -> dispatch (RemoveRandomPool topicId))
form.PickerTopicId |> Option.iter (RemoveRandomPool >> dispatch))
prop.text "Убрать случайный набор, выбирать вручную" prop.text "Убрать случайный набор, выбирать вручную"
] ]
] ]
@@ -301,7 +300,7 @@ let private questionPicker (model: Model) dispatch =
prop.children [ prop.children [
Html.div [ Html.div [
prop.children [ prop.children [
for q in form.PickerQuestions -> for q in topicQuestions ->
Html.label [ Html.label [
prop.key (string q.Id) prop.key (string q.Id)
prop.className "student-row" prop.className "student-row"
@@ -318,7 +317,7 @@ let private questionPicker (model: Model) dispatch =
] ]
Html.button [ Html.button [
prop.type'.button prop.type'.button
prop.onClick (fun _ -> dispatch SelectAllInTopic) prop.onClick (fun _ -> dispatch (SelectAllInTopic topicId))
prop.text "Выбрать все вопросы темы" prop.text "Выбрать все вопросы темы"
] ]
Html.p "или задать случайный набор вместо ручного выбора:" Html.p "или задать случайный набор вместо ручного выбора:"
@@ -326,13 +325,13 @@ let private questionPicker (model: Model) dispatch =
prop.type'.text prop.type'.text
prop.className "pool-count-input" prop.className "pool-count-input"
prop.placeholder "Число вопросов" prop.placeholder "Число вопросов"
prop.value form.PoolCountText prop.value poolCountText
prop.onChange (SetPoolCountText >> dispatch) prop.onChange (fun v -> dispatch (SetPoolCountText(topicId, v)))
] ]
Html.button [ Html.button [
prop.type'.button prop.type'.button
prop.disabled (not fixedInTopic.IsEmpty) prop.disabled (not fixedInTopic.IsEmpty)
prop.onClick (fun _ -> dispatch AddRandomPool) prop.onClick (fun _ -> dispatch (AddRandomPool topicId))
prop.text "Добавить случайный набор из этой темы" prop.text "Добавить случайный набор из этой темы"
] ]
if not fixedInTopic.IsEmpty then 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 else
Html.none for topicId in form.PickerTopicIds |> Set.toList do
topicPickerSection model dispatch topicId
poolRulesSummary model dispatch poolRulesSummary model dispatch
Html.span [ Html.span [
prop.className "tag-mono" prop.className "tag-mono"