Compare commits
5 Commits
b5921292db
...
38ef683b69
| Author | SHA1 | Date | |
|---|---|---|---|
| 38ef683b69 | |||
|
|
8c46ef1972 | ||
|
|
0a1407719e | ||
|
|
ef119dc0dc | ||
|
|
63bdd7dd74 |
@@ -10,13 +10,33 @@ on:
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
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
|
||||
|
||||
|
||||
@@ -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** репозитория должен появиться
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,16 +111,31 @@ 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) =
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ->
|
||||
| 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 questions
|
||||
| Error err -> PickerQuestionsLoadFailed err)
|
||||
(fun ex -> PickerQuestionsLoadFailed ex.Message)
|
||||
| Ok questions -> PickerQuestionsLoaded(topicId, questions)
|
||||
| Error err -> PickerQuestionsLoadFailed(topicId, err))
|
||||
(fun ex -> PickerQuestionsLoadFailed(topicId, ex.Message))
|
||||
|
||||
let existingPoolCount =
|
||||
model.QuizForm.Sources
|
||||
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,19 +268,20 @@ 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
|
||||
match System.Int32.TryParse text with
|
||||
| true, count when count > 0 ->
|
||||
let withoutOldPool =
|
||||
form.Sources
|
||||
@@ -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
|
||||
|
||||
@@ -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,30 +46,27 @@ 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 ->
|
||||
/// 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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -730,6 +746,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 {
|
||||
|
||||
Reference in New Issue
Block a user