Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions RELEASE_NOTES/release-4.2.9.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Release 4.2.9 — 2026-08-03

| | |
| ---------------------------- | -------------------------------------------- |
| **Build branch deployed** | `release-4.2.9` (Jenkins deploy source) |
| **Tag** | `v4.2.9` (immutable marker + GitHub Release) |
| **Baseline (previous prod)** | `v4.2.8` (2026-07-30) |
| **Commits** | `4` |
| **Author** | Likhith Thammegowda |

## Summary

Repairs three visible defects in the quiz player. Question images were not rendering at all
on quizzes that reference images by absolute URL. The connector lines in match-the-following
questions drifted away from their boxes whenever the dialog scrolled, making the question
hard to answer. The Response column of the match-the-following review table was always
empty, so learners could not see what they had actually matched.

Also corrects three smaller issues: a quiz publishing a pass percentage of zero was taken
literally, so every attempt counted as a pass; the quiz overview dialog could not be reopened
after being closed, which left "Yes, Restart" doing nothing; and the competency card on the
ASHA learning list showed untranslated text to Hindi users.

## 🐛 Fixes

- **quiz** — question images now render. The image path resolver stripped the `src="` prefix
with a replace that only matched paths beginning with `/`, so an absolute
`src="https://…"` became `src=https://…` and Angular's sanitizer rewrote it to
`unsafe:src=https://…`. Absolute and `data:` URLs are now left as authored; relative paths
still resolve against the artifact URL (`976e80512`)
- **quiz** — match-the-following connectors stay aligned with their boxes while scrolling.
jsPlumb was instantiated without a `Container`, so it appended its SVG connectors to
`document.body` in page coordinates while the boxes sat inside the scrolling dialog
(`976e80512`)
- **quiz** — the Response column of the match-the-following review table now shows what the
learner matched. Two defects kept it blank: the guard indexed the connection list by option
position, blanking rows past the number of connections; and it compared rendered
`innerText` against the raw option text, which never matched because CSS collapses the
double spaces and ` ` present in the authored content (`976e80512`)
- **assessment** — a `passPercentage` of `0` is now treated as unset and falls back to the
60% default, in both the TOC assessment detail and the viewer's quiz route. Previously only
a missing property triggered the fallback, so a published zero meant every attempt passed
(`31a7127bc`)
- **quiz** — the overview dialog reference is released when the dialog closes, so
"Yes, Restart" reopens it. The reference was only cleared by a commented-out line, leaving
the reopen guard permanently blocked for the life of the component (`dbd513d77`)
- **i18n** — the ASHA learning competency card now uses translation keys that exist in both
locales. It asked for `COMPETENCY`, present in `en.json` but missing from `hi.json`, so
Hindi users saw the raw key rendered; it now uses `Competency` and `Completed`. The missing
`LEVELS` entry was also added to `hi.json` (`585fae92e`)

## 🏗️ Build/CI

- None.

## 📚 Docs/Chore

- None.

## ⚠️ Deploy notes & risk

- **Config / env / secret changes:** none
- **Backend / API contract dependencies:** none. The quiz artifact JSON is unchanged; all
three quiz fixes are client-side rendering corrections
- **Breaking changes:** none
- **Risk note:** all five fixes live under `project/ws/viewer` and `project/ws/app`, which
`jest.config.js` excludes via `testPathIgnorePatterns`. They therefore carry **no unit test
coverage** and the production build is the only automated gate. The image and Response
fixes were verified by replaying the old and new logic against the real quiz artifact
(`do_1146265771989319681410`); the connector and restart fixes are reasoned from the DOM
and dialog lifecycle but were **not confirmed in a browser** — smoke-test both before
declaring the release good.

## ✅ Pre-deploy checklist

- [x] Node 20 active (`nvs use 20`)
- [x] Build verified (`yarn run build:local`)
- [ ] `yarn run lint` clean — pre-existing repo-wide `@typescript-eslint/ban-types`
rule-not-found error blocks a clean lint run (known issue, see CLAUDE.md); no new lint
errors introduced by this release
- [x] Unit tests green (`yarn test`) — no spec exercises the changed paths, see risk note
- [ ] Smoke-tested on preprod — **required for this release**: open a quiz with image
questions, a match-the-following question (scroll while answering, then check the
Response column after submit), and confirm "Yes, Restart" reopens the overview
- [ ] Rollback ref confirmed (re-runnable in Jenkins): `release-4.2.8`

## Release & rollback

**Deploy** — a human runs the manual Jenkins job pointed at the **build branch**
`release-4.2.9` (deploy is from a branch, not a tag). Each release gets its own new build
branch + a `v4.2.9` tag; the previous `release-4.2.8` branch stays frozen.

**Rollback** — re-run the same manual Jenkins job against the previous release branch
`release-4.2.8`.
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export class AssessmentDetailComponent implements OnInit {
question.questionType = 'mcq-sca'
}
})
if (!quizJSON.hasOwnProperty('passPercentage')) {
if (!quizJSON.hasOwnProperty('passPercentage') || quizJSON.passPercentage === 0) {
quizJSON.passPercentage = 60
}
return quizJSON
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,19 +46,31 @@ export class QuestionComponent implements OnInit, AfterViewInit {
private elementRef: ElementRef,
) { }

ngOnInit() {
const res: string[] = this.question.question.match(/<img[^>]+src="([^">]+)"/g) || ['']
for (const oldImg of res) {
if (oldImg) {
let temp = oldImg.match(/src="([^">]+)"/g) || ['']
const toBeReplaced = temp[0]
temp = [temp[0].replace('src="/', '')]
temp = [temp[0].replace(/\"/g, '')]
const baseUrl = this.artifactUrl.split('/')
const newUrl = this.artifactUrl.replace(baseUrl[baseUrl.length - 1], temp[0])
this.question.question = this.question.question.replace(toBeReplaced, `src="${newUrl}"`)
/**
* Only relative image paths need resolving against the artifact URL. Rewriting an absolute
* `src="https://…"` left the `src="` prefix embedded in the value (the old strip only matched
* paths starting with `/`), producing `src=https://…` which Angular sanitized to
* `unsafe:src=https://…` — the image then never loaded.
*/
private resolveQuestionImages() {
const imgTags: string[] = this.question.question.match(/<img[^>]+src="([^">]+)"/g) || []
for (const imgTag of imgTags) {
const srcMatch = imgTag.match(/src="([^">]+)"/)
if (!srcMatch) {
continue
}
const rawSrc = srcMatch[1]
if (/^(https?:)?\/\//i.test(rawSrc) || rawSrc.startsWith('data:')) {
continue
}
const baseUrl = this.artifactUrl.split('/')
const newUrl = this.artifactUrl.replace(baseUrl[baseUrl.length - 1], rawSrc.replace(/^\//, ''))
this.question.question = this.question.question.replace(srcMatch[0], `src="${newUrl}"`)
}
}

ngOnInit() {
this.resolveQuestionImages()
if (this.question.questionType === 'fitb') {
const iterationNumber = (this.question.question.match(/<input/g) || []).length
for (let i = 0; i < iterationNumber; i += 1) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ <h3 *ngIf="question.questionType === 'fitb'" class="m-0" [innerHTML]="safeQuesti

<div (scroll)="repaintEveryThing()" (resize)="repaintEveryThing()">
<div style="height:auto">
<div style="display: flex;flex-direction: column;" [id]="question.questionId">
<!-- position:relative — this is the jsPlumb Container, so the absolutely positioned
connector SVGs must anchor to it rather than to the page. -->
<div style="display: flex;flex-direction: column;position: relative;" [id]="question.questionId">
<div *ngFor="let option of question.options; let i = index">
<div style="display: flex;justify-content: space-around;flex-wrap: wrap; height:auto;">
<div [class]="'question' + question.questionId + ' question'" [id]="'c1' + question.questionId + (i + 1)">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,19 +49,35 @@ export class ViewQuizQuestionComponent implements OnInit, AfterViewInit, OnDestr

}

ngOnInit() {
const res: string[] = this.question.question.match(/<img[^>]+src="([^">]+)"/g) || ['']
for (const oldImg of res) {
if (oldImg) {
let temp = oldImg.match(/src="([^">]+)"/g) || ['']
const toBeReplaced = temp[0]
temp = [temp[0].replace('src="/', '')]
temp = [temp[0].replace(/\"/g, '')]
const baseUrl = this.artifactUrl.split('/')
const newUrl = this.artifactUrl.replace(baseUrl[baseUrl.length - 1], temp[0])
this.question.question = this.question.question.replace(toBeReplaced, `src="${newUrl}"`)
/**
* Quiz HTML can reference images either relatively (packaged alongside the artifact) or by
* absolute URL. Only the relative ones need resolving against the artifact URL.
*
* The previous implementation stripped the `src="` prefix with `.replace('src="/', '')`,
* which only matches when the path starts with `/`. An absolute `src="https://…"` therefore
* kept its prefix and became `src=https://…`, which Angular's sanitizer turned into
* `unsafe:src=https://…` and the image silently failed to load.
*/
private resolveQuestionImages() {
const imgTags: string[] = this.question.question.match(/<img[^>]+src="([^">]+)"/g) || []
for (const imgTag of imgTags) {
const srcMatch = imgTag.match(/src="([^">]+)"/)
if (!srcMatch) {
continue
}
const rawSrc = srcMatch[1]
if (/^(https?:)?\/\//i.test(rawSrc) || rawSrc.startsWith('data:')) {
// Already resolvable — leave it exactly as authored.
continue
}
const baseUrl = this.artifactUrl.split('/')
const newUrl = this.artifactUrl.replace(baseUrl[baseUrl.length - 1], rawSrc.replace(/^\//, ''))
this.question.question = this.question.question.replace(srcMatch[0], `src="${newUrl}"`)
}
}

ngOnInit() {
this.resolveQuestionImages()
if (this.question.questionType === 'fitb') {
const iterationNumber = (this.question.question.match(/<input/g) || []).length
for (let i = 0; i < iterationNumber; i += 1) {
Expand Down Expand Up @@ -137,7 +153,17 @@ export class ViewQuizQuestionComponent implements OnInit, AfterViewInit, OnDestr
}
initJsPlump() {
if (this.question.questionType === 'mtf') {
// Anchor the connectors to the element that holds the boxes. Without an explicit
// Container jsPlumb appends its SVGs to document.body and positions them in page
// coordinates, so once the dialog body scrolls the boxes move but the lines do not —
// which is what made them drift. Sharing a container keeps both in the same scrolling
// coordinate space, so they move together and need no repaint.
// Attribute selector, not `#id`: content ids like `do_114…` are not always valid
// bare CSS identifiers.
const container = this.elementRef.nativeElement
.querySelector(`[id="${this.question.questionId}"]`)
this.jsPlumbInstance = jsPlumb.getInstance({
...(container ? { Container: container } : {}),
DragOptions: {
cursor: 'pointer',
},
Expand Down
5 changes: 4 additions & 1 deletion project/ws/viewer/src/lib/plugins/quiz/quiz.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ export class QuizComponent implements OnInit, OnChanges, OnDestroy {
})

this.dialogOverview.afterClosed().subscribe((result: any) => {
// Release the ref as soon as the overview closes, otherwise the `!this.dialogOverview`
// guard above turns every later call into a no-op for the life of this component —
// which is why "Yes, Restart" never reopened the overview.
this.dialogOverview = null
if (result.event === 'close-overview') {
if (result.competency) {
this.router.navigate([`/app/user/competency`])
Expand Down Expand Up @@ -230,7 +234,6 @@ export class QuizComponent implements OnInit, OnChanges, OnDestroy {
}

}
// this.dialogOverview = null
})
}
}
Expand Down
31 changes: 17 additions & 14 deletions project/ws/viewer/src/lib/plugins/quiz/quiz.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,20 +166,23 @@ export class QuizService {
}
checkMtfAnswer(quiz: NSQuiz.IQuiz, questionAnswerHash: any) {
const userSelectedAnswer: any = quiz.questions[questionAnswerHash['qslideIndex']]
for (let i = 0; i < quiz.questions[questionAnswerHash['qslideIndex']].options.length; i += 1) {
// tslint:disable-next-line: max-line-length
if (questionAnswerHash[quiz.questions[questionAnswerHash['qslideIndex']].questionId] && questionAnswerHash[quiz.questions[questionAnswerHash['qslideIndex']].questionId][0][i]) {
for (let j = 0; j < questionAnswerHash[quiz.questions[questionAnswerHash['qslideIndex']].questionId][0].length; j += 1) {
// tslint:disable-next-line: max-line-length
if (quiz.questions[questionAnswerHash['qslideIndex']].options[i].text.trim() === questionAnswerHash[quiz.questions[questionAnswerHash['qslideIndex']].questionId][0][j].source.innerText.trim()) {
// tslint:disable-next-line: max-line-length
quiz.questions[questionAnswerHash['qslideIndex']].options[i].response = questionAnswerHash[quiz.questions[questionAnswerHash['qslideIndex']].questionId][0][j].target.innerText
}
}
} else {
quiz.questions[questionAnswerHash['qslideIndex']].options[i].response = ''
}
}
const connections: any[] = (questionAnswerHash[userSelectedAnswer.questionId] || [])[0] || []
// Resolve each option to the box the learner actually connected it to, for the Response
// column of the review table. Two bugs used to leave this permanently blank:
//
// 1. The guard tested `connections[i]`, indexing the connection list by option position,
// so when fewer pairs were connected than there are options every row past that count
// was blanked even if it had been answered.
// 2. It compared `source.innerText` against `option.text` directly. innerText returns the
// *rendered* text, and CSS collapses runs of whitespace — option text in the authored
// content frequently contains double spaces, so the two never matched.
const normalize = (value: string) => (value || '').replace(/\s+/g, ' ').trim()
userSelectedAnswer.options.forEach((option: any) => {
const connection = connections.find(
(item: any) => item && item.source && normalize(item.source.innerText) === normalize(option.text)
)
option.response = connection ? connection.target.innerText : ''
})
const matchHintDisplay: any = []
quiz.questions[questionAnswerHash['qslideIndex']].options.map(option => (option.matchForView = option.match))
const array = quiz.questions[questionAnswerHash['qslideIndex']].options.map(elem => elem.match)
Expand Down
3 changes: 3 additions & 0 deletions project/ws/viewer/src/lib/routes/quiz/quiz.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ export class QuizComponent implements OnInit, OnDestroy {
}
})
}
if (!quizJSON.hasOwnProperty('passPercentage') || quizJSON.passPercentage === 0) {
quizJSON.passPercentage = 60
}
this.viewSvc.competencyAsessment.next(true)
return quizJSON
} {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
<div class="asha-card" (click)="toggleExpand()">
<div class="asha-header flex flex-col">
<div class="flex justify-between">
<p class="asha-title mb-0">{{ 'COMPETENCY' | translate }}</p>
<p class="asha-title mb-0">{{ 'Competency' | translate }}</p>
<div class="progress-container" *ngIf="getCompletionPercentage() > 0 && getCompletionPercentage() !== 100 ">
<span class="progress-text">{{ 'COMPLETED' | translate }} {{ getCompletionPercentage() }}% </span>
<span class="progress-text">{{ 'Completed' | translate }} {{ getCompletionPercentage() }}% </span>
</div>
</div>
<div class="flex justify-between gap-4">
Expand Down
1 change: 1 addition & 0 deletions src/assets/i18n/hi.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"Are you sure?": "क्या आप सुनिश्चित हैं?",
"Yes": "हाँ",
"No": "नहीं",
"LEVELS": "स्तर:",
"Content expired or deleted": "सामग्री समाप्त या हटाई गई",
"Content may be expired or deleted": "सामग्री समाप्त या हटाई जा सकती है",
"Intranet content": "इंट्रानेट सामग्री",
Expand Down
Loading