8 Commits

Author SHA1 Message Date
f413ee8e4c Merge pull request 'Show the question bank as a table with a stable, right-aligned actions column' (#2) from feature/question-bank-table into master
All checks were successful
CI/CD / build-test-deploy (push) Successful in 24s
Reviewed-on: #2
2026-08-09 16:48:18 +00:00
danamir
24cce8ea71 Show the question bank as a table with a stable, right-aligned actions column
All checks were successful
CI/CD / build-test-deploy (pull_request) Successful in 1m39s
The old list showed type, text, and points inline via flexbox, with no
correct-answer info at all, and unstyled buttons that shifted position
depending on question text length. A table with a shrink-to-fit
actions column (text/kind/answer/points first, buttons last) fixes
both: correct answers are now visible, and button position no longer
depends on text length.
2026-08-09 19:03:40 +03:00
danamir
b7177c455e Fix CI/CD deploy targeting the wrong compose project
All checks were successful
CI/CD / build-test-deploy (push) Successful in 29s
COMPOSE_PROJECT_NAME was "ruvdstests" but the live stack was actually
deployed under "ruvdstest" (no trailing s) — Compose treated it as an
unrelated project and spun up a whole second stack from scratch,
colliding with the real one's host port and aborting mid-deploy
instead of updating the running containers in place.
2026-08-09 01:29:10 +03:00
38ef683b69 Merge pull request 'feature/teacher-quiz-form' (#1) from feature/teacher-quiz-form into master
Some checks failed
CI/CD / build-test-deploy (push) Failing after 31s
Reviewed-on: #1
2026-08-08 22:15:02 +00:00
danamir
8c46ef1972 Wire JWT_SECRET/POSTGRES_PASSWORD through Gitea Actions secrets
All checks were successful
CI/CD / build-test-deploy (pull_request) Successful in 4m43s
The job checks out into the runner's own workspace, not
/opt/ruvdstests where DEPLOY.md has the human create a .env file — so
docker compose here had no .env to read JWT_SECRET from and failed
outright. POSTGRES_PASSWORD would have silently fallen back to the
compose file's devpassword default instead of erroring, which on
Deploy would have broken auth against the already-initialized pgdata
volume.
2026-08-09 01:04:13 +03:00
danamir
0a1407719e Install Node.js before Checkout in the CI/CD workflow
Some checks failed
CI/CD / build-test-deploy (pull_request) Failing after 53s
Host-mode jobs execute directly on the bare runner container rather
than a per-job container, and actions/checkout is a JS action — it
needs `node` on PATH to run at all, which the runner image doesn't
ship. Failed every run with "Cannot find: node in PATH" before any
other step got a chance to install anything.
2026-08-09 01:00:12 +03:00
danamir
ef119dc0dc Show one question per card in the take-quiz screen, with prev/next arrows
Some checks failed
CI/CD / build-test-deploy (pull_request) Failing after 2s
Replaces the single long scrollable list of every question with one
card at a time plus arrow navigation, so a student's focus stays on
the current question instead of the whole quiz at once.
2026-08-09 00:55:01 +03:00
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
10 changed files with 409 additions and 172 deletions

View File

@@ -9,14 +9,38 @@ on:
# Pinned so every run targets the same project regardless of the runner's
# checkout path — otherwise `docker compose` would derive the project name
# from the checkout directory, potentially spinning up a second stack and
# losing the `pgdata` volume instead of updating the running one.
# losing the `pgdata` volume instead of updating the running one. Must be
# `ruvdstest` (no trailing "s") to match the project name the live stack was
# actually deployed under — a one-letter mismatch here previously made
# Compose treat it as an unrelated project and spin up a whole second stack
# from scratch instead of recognizing and updating the running containers.
#
# JWT_SECRET/POSTGRES_PASSWORD come from Gitea's own Actions secrets store
# rather than the `.env` file DEPLOY.md has the human create in
# `/opt/ruvdstests` — the job checks out into the runner's own workspace, not
# that directory, so there's no `.env` for `docker compose` to read here.
# Must match the values already in that `.env` file: on `Deploy` this
# `docker compose up -d` targets the same running project (via
# COMPOSE_PROJECT_NAME above), and a different POSTGRES_PASSWORD than what
# the live `pgdata` volume was initialized with breaks the DB connection.
env:
COMPOSE_PROJECT_NAME: ruvdstests
COMPOSE_PROJECT_NAME: ruvdstest
JWT_SECRET: ${{ secrets.JWT_SECRET }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
jobs:
build-test-deploy:
runs-on: host
steps:
# `runs-on: host` runs every step directly on the bare runner
# container instead of spinning up a fresh container per job, and
# `actions/checkout` below is a JS action — it needs a `node` binary
# on PATH to run at all, which this image doesn't ship. Has to come
# before Checkout, since Checkout itself is what fails without it.
- name: Install Node.js
shell: bash
run: apk add --no-cache nodejs
- name: Checkout
uses: https://github.com/actions/checkout@v4

View File

@@ -37,13 +37,25 @@ docker run -d --name gitea-ci-runner --restart unless-stopped \
`host:host` — раннер выполняет джобы напрямую в своём собственном контейнере (никаких вложенных
контейнеров на каждый джоб), у него смонтирован docker.sock хоста — поэтому `docker compose`
внутри джоба управляет реальными контейнерами на этой машине. Джоб сам ставит себе `docker-cli` и
.NET SDK через `apk`/`dotnet-install.sh` (см. workflow) — образ раннера намеренно голый, чтобы не
поддерживать отдельный кастомный образ.
внутри джоба управляет реальными контейнерами на этой машине. Джоб сам ставит себе `node`,
`docker-cli` и .NET SDK через `apk`/`dotnet-install.sh` (см. workflow) — образ раннера намеренно
голый, чтобы не поддерживать отдельный кастомный образ. `node` ставится первым шагом, до
`actions/checkout` — этот шаг сам является JS-экшеном и без `node` в PATH падает с `Cannot find:
node in PATH` раньше, чем успевает выполниться что-либо ещё.
Проверить, что раннер подключился: Site Administration → Actions → Runners — должен появиться
`ruvdstest-prod` со статусом Idle/Online.
## Секреты
Джоб чекаутит репозиторий во временную директорию раннера, а не в `/opt/ruvdstests` — своего
`.env` там нет, поэтому `JWT_SECRET`/`POSTGRES_PASSWORD` для `docker compose build`/`up` берутся не
из файла, а из хранилища секретов самой Gitea Actions: Settings репозитория → Actions → Secrets →
**Add Secret**. Завести `JWT_SECRET` и `POSTGRES_PASSWORD` со **значениями, совпадающими с тем, что
уже лежит в `/opt/ruvdstests/.env`** на сервере (`cat /opt/ruvdstests/.env` на сервере, скопировать
оттуда) — деплой-шаг пересоздаёт тот же самый запущенный проект (`COMPOSE_PROJECT_NAME` выше), и
рассинхронизация паролей с уже инициализированным `pgdata`-volume сломает подключение к БД.
## Проверка
Сделайте любой коммит и запушьте в `master` — во вкладке **Actions** репозитория должен появиться

View File

@@ -53,6 +53,9 @@ let rec update (token: string option) (msg: Msg) (model: Model) : Model * Cmd<Ms
updated, cmd
| AnswerSaved _ -> { model with Error = None }, Cmd.none
| AnswerSaveFailed(_, err) -> { model with Error = Some err }, Cmd.none
| GoToQuestion index ->
let clamped = index |> max 0 |> min (List.length model.Data.Questions - 1)
{ model with CurrentIndex = clamped }, Cmd.none
| Tick ->
let updated = { model with RemainingSeconds = remainingSeconds model.Data DateTimeOffset.UtcNow }
// The server is the actual authority (it now rejects any answer

View File

@@ -18,6 +18,10 @@ let remainingSeconds (data: QuizForAttempt) (now: DateTimeOffset) : int option =
type Model =
{ Data: QuizForAttempt
Answers: Map<QuestionId, StudentResponse>
/// Index into `Data.Questions` of the question currently shown on its
/// own card questions are navigated one at a time via prev/next
/// arrows rather than shown all at once.
CurrentIndex: int
Error: string option
IsFinishing: bool
RemainingSeconds: int option
@@ -30,6 +34,7 @@ type Model =
let init (data: QuizForAttempt) : Model =
{ Data = data
Answers = Map.empty
CurrentIndex = 0
Error = None
IsFinishing = false
RemainingSeconds = remainingSeconds data DateTimeOffset.UtcNow
@@ -39,6 +44,7 @@ type Msg =
| AnswerChanged of QuestionId * StudentResponse
| AnswerSaved of QuestionId
| AnswerSaveFailed of QuestionId * string
| GoToQuestion of int
| Tick
| FocusLost
| FocusRegained

View File

@@ -5,7 +5,7 @@ open Domain
open Domain.Contracts
open Client.Features.Quizzes.TakeQuiz.Types
let private questionView (answers: Map<QuestionId, StudentResponse>) dispatch (index: int) (question: QuestionView) =
let private questionBody (answers: Map<QuestionId, StudentResponse>) dispatch (question: QuestionView) =
let groupName = string question.Id
let body =
@@ -111,18 +111,33 @@ let private questionView (answers: Map<QuestionId, StudentResponse>) dispatch (i
dispatch (AnswerChanged(question.Id, NumericResponse parsed)))
]
body
let private questionCard (model: Model) dispatch (question: QuestionView) =
let total = List.length model.Data.Questions
Html.div [
prop.key (string question.Id)
prop.className "question"
prop.className "question-card"
prop.children [
Html.span [ prop.className "question-number"; prop.text (sprintf "№ %02d" (index + 1)) ]
Html.div [
prop.className "question-body"
prop.children [ Html.p [ prop.className "question-text"; prop.text question.Text ]; body ]
Html.span [
prop.className "question-number"
prop.text (sprintf "Вопрос %d из %d" (model.CurrentIndex + 1) total)
]
Html.p [ prop.className "question-text"; prop.text question.Text ]
questionBody model.Answers dispatch question
]
]
let private navArrow (className: string) (label: string) (enabled: bool) (onClick: unit -> unit) =
Html.button [
prop.type'.button
prop.className (sprintf "nav-arrow %s" className)
prop.disabled (not enabled)
prop.onClick (fun _ -> onClick ())
prop.text label
]
let private timeRemainingBadge (seconds: int) =
Html.span [
prop.className (if seconds <= 60 then "time-remaining low" else "time-remaining")
@@ -130,6 +145,8 @@ let private timeRemainingBadge (seconds: int) =
]
let view (model: Model) (dispatch: Msg -> unit) =
let total = List.length model.Data.Questions
Html.div [
prop.className "taking-quiz-page"
prop.children [
@@ -138,7 +155,16 @@ let view (model: Model) (dispatch: Msg -> unit) =
| Some err -> Html.p [ prop.className "error"; prop.text err ]
| None -> Html.none
Html.div [
prop.children (model.Data.Questions |> List.mapi (questionView model.Answers dispatch))
prop.className "question-nav"
prop.children [
navArrow "nav-arrow-prev" "" (model.CurrentIndex > 0) (fun () ->
dispatch (GoToQuestion(model.CurrentIndex - 1)))
match model.Data.Questions |> List.tryItem model.CurrentIndex with
| Some question -> questionCard model dispatch question
| None -> Html.none
navArrow "nav-arrow-next" "" (model.CurrentIndex < total - 1) (fun () ->
dispatch (GoToQuestion(model.CurrentIndex + 1)))
]
]
Html.div [
prop.className "quiz-actions"

View File

@@ -65,6 +65,25 @@ let private kindLabel (t: QuestionTypeView) =
| ShortAnswerT _ -> "Короткий ответ"
| NumericT _ -> "Числовой"
/// Read-only rendering of a question's correct answer(s) for the teacher's
/// question-bank table distinct from `formFromSummary` in Types.fs, which
/// unpacks the same data to prefill the edit form instead of displaying it.
let private correctAnswerText (t: QuestionTypeView) =
match t with
| SingleChoiceT d ->
d.Options
|> List.tryFind (fun o -> o.Id = d.CorrectOptionId)
|> Option.map (fun o -> o.Text)
|> Option.defaultValue "—"
| MultipleChoiceT d ->
d.Options
|> List.filter (fun o -> List.contains o.Id d.CorrectOptionIds)
|> List.map (fun o -> o.Text)
|> String.concat ", "
| TrueFalseT b -> if b then "Верно" else "Неверно"
| ShortAnswerT d -> String.concat ", " d.AcceptedAnswers
| NumericT d -> sprintf "%s ± %s" (Format.points d.CorrectValue) (Format.points d.Tolerance)
let private questionsListView (model: Model) dispatch =
Html.div [
prop.className "questions-list"
@@ -78,30 +97,45 @@ let private questionsListView (model: Model) dispatch =
elif model.Questions.IsEmpty then
Html.p "В этой теме пока нет вопросов"
else
Html.ul [
Html.table [
prop.className "questions-table"
prop.children [
for q in model.Questions ->
Html.li [
prop.key (string q.Id)
prop.children [
Html.span [ prop.className "question-kind"; prop.text (kindLabel q.Type) ]
Html.text (sprintf " %s " q.Text)
Html.span [
prop.className "tag-mono"
prop.text (sprintf "%s б." (Format.points q.Points))
]
Html.button [
prop.type'.button
prop.onClick (fun _ -> dispatch (StartEditQuestion q.Id))
prop.text "Изменить"
]
Html.button [
prop.type'.button
prop.onClick (fun _ -> dispatch (RequestDeleteQuestion q.Id))
prop.text "Удалить"
Html.thead [
Html.tr [
Html.th [ prop.text "Текст вопроса" ]
Html.th [ prop.text "Тип" ]
Html.th [ prop.text "Правильный ответ" ]
Html.th [ prop.text "Баллы" ]
Html.th []
]
]
Html.tbody [
for q in model.Questions ->
Html.tr [
prop.key (string q.Id)
prop.children [
Html.td [ prop.text q.Text ]
Html.td [ prop.className "question-kind"; prop.text (kindLabel q.Type) ]
Html.td [ prop.text (correctAnswerText q.Type) ]
Html.td [ prop.className "tag-mono"; prop.text (Format.points q.Points) ]
Html.td [
prop.className "question-actions-cell"
prop.children [
Html.button [
prop.type'.button
prop.onClick (fun _ -> dispatch (StartEditQuestion q.Id))
prop.text "Изменить"
]
Html.button [
prop.type'.button
prop.onClick (fun _ -> dispatch (RequestDeleteQuestion q.Id))
prop.text "Удалить"
]
]
]
]
]
]
]
]
]
]

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
| 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<Msg> =
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<Msg> =
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<Msg> =
| 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

View File

@@ -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<TopicId>
/// 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<TopicId>
/// Draft text for "N случайных вопросов", one per expanded topic.
PoolCountTexts: Map<TopicId, string>
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

View File

@@ -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"

View File

@@ -297,28 +297,27 @@ a {
/* ============================================================
Taking a quiz
============================================================ */
.question {
.question-nav {
display: flex;
gap: 1rem;
border-top: 1px solid var(--line);
padding: 1.25rem 0;
align-items: center;
gap: 0.75rem;
}
.question:first-of-type {
border-top: none;
.question-card {
flex: 1;
min-width: 0;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1.5rem;
}
.question-number {
display: block;
font-family: var(--font-mono);
font-size: 0.85rem;
color: var(--brass);
flex-shrink: 0;
padding-top: 0.15rem;
}
.question-body {
flex: 1;
min-width: 0;
margin-bottom: 0.6rem;
}
.question-text {
@@ -326,6 +325,23 @@ a {
margin-bottom: 0.6rem;
}
.nav-arrow {
flex-shrink: 0;
width: 2.5rem;
height: 2.5rem;
padding: 0;
font-size: 1.3rem;
line-height: 1;
border-radius: 50%;
background: var(--surface);
color: var(--ink);
border: 1px solid var(--line);
}
.nav-arrow:hover:not(:disabled) {
background: var(--brass-soft);
}
.option {
display: flex;
align-items: center;
@@ -665,17 +681,44 @@ a {
margin-bottom: 1.25rem;
}
.questions-list li {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
border-top: 1px solid var(--line);
padding: 0.6rem 0;
.questions-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.questions-list li:first-child {
border-top: none;
.questions-table th,
.questions-table td {
text-align: left;
padding: 0.55rem 0.75rem;
border-bottom: 1px solid var(--line);
vertical-align: top;
}
.questions-table th {
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ink-soft);
}
.questions-table tbody tr:hover {
background: var(--brass-soft);
}
/* `width: 1%` shrinks this column to just its buttons' content width —
since it's the only unconstrained column left, the question-text column
absorbs 100% of the remaining space instead, so the buttons stay pinned
flush right regardless of how long any question's text is. */
.question-actions-cell {
white-space: nowrap;
width: 1%;
text-align: right;
}
.question-actions-cell button {
margin-left: 0.5rem;
}
.question-kind {
@@ -684,7 +727,6 @@ a {
color: var(--brass);
text-transform: uppercase;
letter-spacing: 0.03em;
margin-right: 0.5rem;
}
.new-question-form {
@@ -730,6 +772,24 @@ a {
padding: 0.3rem 0;
}
.topic-checkbox-list {
display: flex;
flex-wrap: wrap;
gap: 0 1rem;
margin-bottom: 0.5rem;
}
.topic-picker-section {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.75rem 1rem;
margin-bottom: 0.75rem;
}
.topic-picker-section h4 {
margin: 0 0 0.5rem;
}
/* ---- Modal overlay (quiz create/edit form) ---- */
.modal-backdrop {