Skip to content

Questionnaire actions - #16618

Open
bodhish wants to merge 88 commits into
developfrom
bodhi/questionnaire-actions
Open

Questionnaire actions#16618
bodhish wants to merge 88 commits into
developfrom
bodhi/questionnaire-actions

Conversation

@bodhish

@bodhish bodhish commented Jul 31, 2026

Copy link
Copy Markdown
Member

Proposed Changes

Fixes #issue_number

  • Change 1
  • Change 2
  • Additional context if needed

Tagging: @ohcnetwork/care-fe-code-reviewers

Merge Checklist

  • Add specs that demonstrate the bug or test the new feature.
  • Update product documentation.
  • Ensure that UI text is placed in I18n files.
  • Prepare a screenshot or demo video for the changelog entry and attach it to the issue.
  • Request peer reviews.
  • Complete QA on mobile devices.
  • Complete QA on desktop devices.
  • Add or update Playwright tests for related changes

Summary by CodeRabbit

  • New Features
    • Added Questionnaire v2 management for admin and facility settings, including creation, search, editing, preview, cloning, importing, version history, and organization assignment.
    • Added a questionnaire builder with question types, answer options, visibility rules, validation, reordering, coding, and save protection.
    • Added rendering for common inputs, grouped questions, conditional visibility, notes, navigation, and preview/readonly modes.
    • Added value-set search, selection, creation, favorites, and recent-item support.
  • Bug Fixes
    • Improved identifier-based routing, dialog state handling, navigation, and form submission safeguards.
  • Documentation
    • Added Questionnaire v2 documentation and updated English translations.
  • Tests
    • Added comprehensive end-to-end coverage for questionnaire workflows, permissions, scoping, versions, and facility settings.

bodhish and others added 22 commits July 30, 2026 00:10
…y to writable fields

- useForm: add resetOptions.keepDirtyValues so a background refetch (e.g.
  triggered by a reorder save invalidating ["questionnairesV2"]) no longer
  wipes an in-progress unsaved title/slug/description/status edit.
- buildUpdateBody: construct the PUT body explicitly from only the writable
  QuestionnaireUpdate/QuestionnaireBase fields instead of spreading the full
  QuestionnaireRead, so read-only fields (id, auth_context,
  internal_revision, created_by, updated_by, modified_date) are never sent.
- QuestionOverviewList: disable the reorder up/down buttons while the save
  mutation is pending to avoid a double-click lost-update race.
- QuestionOverviewList: use a translated action label for the expand/collapse
  chevron instead of the question text; add toggle_sub_questions key.
- Add a "versions" i18n key and use it for the versions tab trigger instead
  of the singular "version".
Entire-Checkpoint: 1ef0119d1d60
…to satisfy unique component auto-registration

Entire-Checkpoint: a02b0db6d749
…, self-moves, and nested-selection duplication

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ree-wide move targets

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…enderer

Adds the QuestionnaireBuilderPage route (Edit|Preview toggle, validated
save flow, sticky footer navigation) and mounts it above the detail
route in both the facility settings and admin routers. Preview renders
the real QuestionnaireRenderer against the in-progress draft.

Also: extracts buildUpdateBody into a shared manage/ module reused by
both the detail page and the builder; fixes the Question Title
label/input association in QuestionEditorCard so it has a proper
accessible name; and hides QuestionnaireRenderer's tree nav when there
is only one top-level question, since a single-item nav duplicated the
active question's title next to its own field label.
@bodhish
bodhish requested review from a team and Copilot July 31, 2026 17:22
@bodhish
bodhish requested a review from a team as a code owner July 31, 2026 17:22
@github-actions

Copy link
Copy Markdown

⚠️ Merge Checklist Incomplete

Thank you for your contribution! To help us review your PR efficiently, please complete the merge checklist in your PR description.

Your PR will be reviewed once you have marked the appropriate checklist items.

To update the checklist:

  • Change - [ ] to - [x] for completed items
  • Only check items that are relevant to your PR
  • Leave items unchecked if they don't apply

The checklist helps ensure code quality, testing coverage, and documentation are properly addressed.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
public/locale/en.json (1)

388-558: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Append new English locale keys at the end of the file.

The new keys are inserted into alphabetical positions throughout public/locale/en.json. Move the added keys to the end of the English locale file. Keep non-English locale files unchanged.

As per coding guidelines, “When an i18n key is missing, append the key to the end of the English locale file; do not directly edit non-English locale files.”

Also applies to: 648-714, 1008-1008, 1189-1312, 1392-1432, 1536-1578, 1717-1789, 1828-1829, 2010-2049, 2117-2394, 2541-2595, 2698-2864, 3110-3134, 3284-3303, 3549-3561, 3860-3971, 4135-4230, 4459-4460, 4547-4752, 4773-4910, 5183-5401, 5531-5639, 5732-5786, 5819-5945, 6082-6172, 6293-6320, 6374-6380, 6458-6458, 6562-6563

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@public/locale/en.json` around lines 388 - 558, Move all newly added
translation keys in public/locale/en.json from their alphabetical insertion
points to the end of the file, preserving each key-value pair and valid JSON
formatting; leave all non-English locale files unchanged.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/facility/settings/questionnaires/questionnaireBuilderMatrix.spec.ts`:
- Around line 298-343: Replace the styling-dependent locator in the “Preview
shows the required asterisk” step with a semantic or accessible selector that
identifies the required-field marker. Update the assertion around the question
label without relying on the span.text-red-500 Tailwind class, preserving
verification that the required marker is visible.

In
`@tests/facility/settings/questionnaires/questionnaireConditionsAuthoring.spec.ts`:
- Around line 34-37: Update conditionGrid to locate the condition editor using a
stable semantic selector or accessible test hook from the rendered condition UI
instead of matching the Tailwind class substring sm:grid-cols-2. Keep the
helper’s scope limited to identifying the field grid and preserve its existing
Page locator behavior.

In `@tests/facility/settings/questionnaires/questionnaireDetailActions.spec.ts`:
- Around line 77-103: Replace the generic div and Tailwind-class row selectors
in the questionnaire ordering test with stable data-slot-based selectors,
including rows, firstRow, and firstOrdinalRow. Reuse the existing data-slot
selector convention and preserve the current text and ordinal filtering
behavior.

In
`@tests/facility/settings/questionnaires/questionnaireEnableWhenMatrix.spec.ts`:
- Around line 19-36: Extract openKitchenSinkPreview, navRow, and jumpTo into
tests/helper/questionnaireV2.ts, then import and reuse them in
tests/facility/settings/questionnaires/questionnaireEnableWhenMatrix.spec.ts
(lines 19-36) and remove its local definitions. In
tests/facility/settings/questionnaires/questionnairePreviewTypes.spec.ts (lines
18-33), remove the duplicated openKitchenSinkPreview and jumpTo helpers and
import the shared implementations.

In `@tests/helper/questionnaireV2.ts`:
- Around line 38-65: Update getQuestionnaireIdBySlug to follow the questionnaire
list endpoint’s pagination, checking each page’s results for the requested slug
and stopping immediately when found. Continue requesting the next page until
next is absent, then throw the existing not-found error only after all pages
have been searched; preserve the existing response-error handling and cache
behavior.

---

Outside diff comments:
In `@public/locale/en.json`:
- Around line 388-558: Move all newly added translation keys in
public/locale/en.json from their alphabetical insertion points to the end of the
file, preserving each key-value pair and valid JSON formatting; leave all
non-English locale files unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 55d93f98-cf57-4dbe-a48a-db1d6a75143a

📥 Commits

Reviewing files that changed from the base of the PR and between 2b26f69 and b7c1dfa.

📒 Files selected for processing (29)
  • public/locale/en.json
  • src/components/Questionnaire/CodingEditor.tsx
  • src/components/QuestionnaireV2/README.md
  • src/components/QuestionnaireV2/builder/QuestionCodingCard.tsx
  • src/components/QuestionnaireV2/builder/QuestionEditorCard.tsx
  • src/components/QuestionnaireV2/useCanWriteQuestionnaire.ts
  • src/pages/Facility/utils/useCurrentFacility.tsx
  • tests/admin/questionnaires/questionnaireOrganizations.spec.ts
  • tests/admin/questionnaires/questionnairePermissions.spec.ts
  • tests/admin/questionnaires/questionnaireScoping.spec.ts
  • tests/admin/questionnaires/questionnaireVersionsDepth.spec.ts
  • tests/facility/patient/encounter/forms/enableWhen/boolean.spec.ts
  • tests/facility/patient/encounter/forms/enableWhen/string.spec.ts
  • tests/facility/settings/questionnaires/questionnaireBuilder.spec.ts
  • tests/facility/settings/questionnaires/questionnaireBuilderGroups.spec.ts
  • tests/facility/settings/questionnaires/questionnaireBuilderMatrix.spec.ts
  • tests/facility/settings/questionnaires/questionnaireBuilderNavigation.spec.ts
  • tests/facility/settings/questionnaires/questionnaireConditionsAuthoring.spec.ts
  • tests/facility/settings/questionnaires/questionnaireCreateValidation.spec.ts
  • tests/facility/settings/questionnaires/questionnaireDetailActions.spec.ts
  • tests/facility/settings/questionnaires/questionnaireEnableWhenMatrix.spec.ts
  • tests/facility/settings/questionnaires/questionnaireErrorHandling.spec.ts
  • tests/facility/settings/questionnaires/questionnaireImportUrl.spec.ts
  • tests/facility/settings/questionnaires/questionnaireMobile.spec.ts
  • tests/facility/settings/questionnaires/questionnairePagination.spec.ts
  • tests/facility/settings/questionnaires/questionnairePreviewTypes.spec.ts
  • tests/helper/questionnaireV2.ts
  • tests/setup/questionnaire.setup.ts
  • tests/support/questionnaireId.ts
💤 Files with no reviewable changes (1)
  • src/components/Questionnaire/CodingEditor.tsx

Comment on lines +298 to +343
test("required flag renders an asterisk; repeats hides for boolean and clears on type change", async ({
page,
}) => {
const facilityId = getFacilityId();
const questionTitle = `Mandatory field ${Date.now()}`;

await createQuestionnaireAndOpenBuilder(page, {
basePath: `/facility/${facilityId}/settings/questionnaires`,
title: `QV2 Flags ${Date.now()}`,
});
await addQuestion(page, questionTitle);

const repeatable = page.getByRole("checkbox", { name: "Repeatable" });

await test.step("Mark Required and Repeatable", async () => {
await page.getByRole("button", { name: BEHAVIOUR_CARD_NAME }).click();
await page.getByRole("checkbox", { name: "Required" }).click();
await repeatable.click();
await expect(repeatable).toHaveAttribute("aria-checked", "true");
await expect(page.getByText("Configured • 2")).toBeVisible();
});

await test.step("Boolean never offers Repeats — and clears the flag", async () => {
await pickType(page, "boolean");
await expect(repeatable).not.toBeVisible();
await expect(
page.getByRole("checkbox", { name: "Required" }),
).toHaveAttribute("aria-checked", "true");

await pickType(page, "string");
await expect(repeatable).toBeVisible();
await expect(repeatable).toHaveAttribute("aria-checked", "false");
await expect(page.getByText("Configured • 1")).toBeVisible();
});

await test.step("Preview shows the required asterisk", async () => {
await page.getByRole("button", { name: "Save Changes" }).click();
await expectToast(page, "Questionnaire updated successfully");
await page.getByRole("button", { name: "Preview" }).click();
await expect(
page.locator("label").filter({ hasText: questionTitle }),
).toBeVisible();
await expect(page.locator("span.text-red-500")).toBeVisible();
});
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial

Selecting the required-field marker by raw Tailwind class is brittle.

Line 340 locates the required asterisk with page.locator("span.text-red-500"). This ties test correctness to a styling implementation detail. This finding is addressed together with the conditionGrid selector in questionnaireConditionsAuthoring.spec.ts in the consolidated comment below.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/facility/settings/questionnaires/questionnaireBuilderMatrix.spec.ts`
around lines 298 - 343, Replace the styling-dependent locator in the “Preview
shows the required asterisk” step with a semantic or accessible selector that
identifies the required-field marker. Update the assertion around the question
label without relying on the span.text-red-500 Tailwind class, preserving
verification that the required marker is visible.

Source: Learnings

Comment on lines +34 to +37
/** The condition editor's field grid (question/operator/answer). */
function conditionGrid(page: Page) {
return page.locator('div[class*="sm:grid-cols-2"]');
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial

Selecting elements by raw Tailwind class substring is brittle.

conditionGrid locates the condition row by matching the class attribute against sm:grid-cols-2. A future layout change to VisibilityConditionsCard.tsx (e.g., a different Tailwind breakpoint or grid column count) breaks this test without any change to test intent. This finding is addressed together with a similar pattern in questionnaireBuilderMatrix.spec.ts in the consolidated comment below.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@tests/facility/settings/questionnaires/questionnaireConditionsAuthoring.spec.ts`
around lines 34 - 37, Update conditionGrid to locate the condition editor using
a stable semantic selector or accessible test hook from the rendered condition
UI instead of matching the Tailwind class substring sm:grid-cols-2. Keep the
helper’s scope limited to identifying the field grid and preserve its existing
Page locator behavior.

Source: Learnings

Comment on lines +77 to +103
const rows = page.locator("div").filter({ hasText: /^1\./ });

await test.step("Move the first question down via its kebab", async () => {
await expect(page.getByText(first)).toBeVisible();
const firstRow = page
.locator("div")
.filter({ has: page.getByText(first, { exact: true }) })
.filter({ has: page.getByRole("button", { name: "More options" }) })
.last();
await firstRow.getByRole("button", { name: "More options" }).click();
await page.getByRole("menuitem", { name: "Move Down" }).click();
await expectToast(page, "Questionnaire updated successfully");
});

await test.step("The order is persisted after reload", async () => {
await page.reload();
await expect(rows.last()).toContainText(second);
const orderedTitles = page.locator(
'[data-slot="badge"]:has-text("String")',
);
await expect(orderedTitles).toHaveCount(2);
// Row 1 now holds the previously-second question.
const firstOrdinalRow = page
.locator("div.min-w-0.flex-1", { hasText: /^1\./ })
.first();
await expect(firstOrdinalRow).toContainText(second);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use stable data-slot selectors instead of generic div/Tailwind-class chains.

The row locators at lines 77, 81-85, and 99-102 match on generic div elements filtered by text content, or on Tailwind utility classes (div.min-w-0.flex-1). Both approaches are fragile: nested ancestor divs all satisfy hasText/has filters, and any future Tailwind class rename breaks the test without a real behavior change.

The same test already uses a stable pattern at lines 94-97 ([data-slot="badge"]:has-text("String")). Apply the same data-slot convention to the row locators.

Based on learnings, "In the ohcnetwork/care_fe repository, prefer using data-slot attributes for test selectors over data-testid... Ensure selectors query by data-slot".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/facility/settings/questionnaires/questionnaireDetailActions.spec.ts`
around lines 77 - 103, Replace the generic div and Tailwind-class row selectors
in the questionnaire ordering test with stable data-slot-based selectors,
including rows, firstRow, and firstOrdinalRow. Reuse the existing data-slot
selector convention and preserve the current text and ordinal filtering
behavior.

Source: Learnings

Comment on lines +19 to +36
async function openKitchenSinkPreview(page: Page): Promise<void> {
const facilityId = getFacilityId();
const id = await getQuestionnaireIdBySlug(KITCHEN_SINK_FACILITY_SLUG);
await page.goto(
`/facility/${facilityId}/settings/questionnaires/${id}/edit?mode=preview`,
);
await expect(page.getByRole("navigation")).toBeVisible();
}

function navRow(page: Page, questionText: string): Locator {
return page
.getByRole("navigation")
.getByRole("button", { name: questionText });
}

async function jumpTo(page: Page, questionText: string): Promise<void> {
await navRow(page, questionText).click();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Extract the duplicated kitchen-sink preview navigation helpers to the shared test helper module. Both files define openKitchenSinkPreview with an identical body, and both define a jumpTo helper that performs the same tree-nav button click; both files already import other helpers (getQuestionnaireIdBySlug, slug constants) from tests/helper/questionnaireV2, so the shared root cause is that this navigation logic was copy-pasted instead of being added there.

  • tests/facility/settings/questionnaires/questionnaireEnableWhenMatrix.spec.ts#L19-L36: move openKitchenSinkPreview, navRow, and jumpTo into tests/helper/questionnaireV2.ts and import them here.
  • tests/facility/settings/questionnaires/questionnairePreviewTypes.spec.ts#L18-L33: drop the local openKitchenSinkPreview/jumpTo definitions and import the same shared helpers instead of re-declaring them.
📍 Affects 2 files
  • tests/facility/settings/questionnaires/questionnaireEnableWhenMatrix.spec.ts#L19-L36 (this comment)
  • tests/facility/settings/questionnaires/questionnairePreviewTypes.spec.ts#L18-L33
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/facility/settings/questionnaires/questionnaireEnableWhenMatrix.spec.ts`
around lines 19 - 36, Extract openKitchenSinkPreview, navRow, and jumpTo into
tests/helper/questionnaireV2.ts, then import and reuse them in
tests/facility/settings/questionnaires/questionnaireEnableWhenMatrix.spec.ts
(lines 19-36) and remove its local definitions. In
tests/facility/settings/questionnaires/questionnairePreviewTypes.spec.ts (lines
18-33), remove the duplicated openKitchenSinkPreview and jumpTo helpers and
import the shared implementations.

Comment on lines +38 to +65
const slugCache = new Map<string, string>();

/**
* Resolves a fixture questionnaire's id from its slug. The detail endpoint
* looks up by external_id only (ENG-737 dropped slug lookup) and the list
* has no slug filter, so this lists broadly and matches client-side.
*/
export async function getQuestionnaireIdBySlug(slug: string): Promise<string> {
const cached = slugCache.get(slug);
if (cached) return cached;
const res = await fetch(`${apiBaseUrl()}/api/v1/questionnaire/?limit=100`, {
headers: adminApiHeaders(),
});
if (!res.ok) {
throw new Error(`Failed to list questionnaires: ${res.status}`);
}
const data = (await res.json()) as {
results: { id: string; slug: string }[];
};
const match = data.results.find((entry) => entry.slug === slug);
if (!match) {
throw new Error(
`Fixture questionnaire "${slug}" not found — reload backend E2E fixtures`,
);
}
slugCache.set(slug, match.id);
return match.id;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does Django REST Framework's default LimitOffsetPagination response include a "next" field alongside "results"?

💡 Result:

Yes, the default response for Django REST Framework's LimitOffsetPagination includes a next field alongside the results [1][2]. When using LimitOffsetPagination, the API response is formatted as a dictionary containing the following keys: count, next, previous, and results [2][3][4]. The next field provides the URL for the next page of results, or null if no further results are available [2][3][4]. The structure of the response is as follows: { "count": 1023, "next": "https://api.example.org/accounts/?limit=100&offset=500", "previous": "https://api.example.org/accounts/?limit=100&offset=300", "results": [... ] } [1]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate relevant files =="
fd -a 'questionnaireV2\.ts|questionnaire|Questionnaire|questionnaire' . | sed 's#^\./##' | head -200

echo
echo "== read helper =="
if [ -f tests/helper/questionnaireV2.ts ]; then
  cat -n tests/helper/questionnaireV2.ts
else
  fd -a 'questionnaireV2.ts' . | while read -r f; do echo "--- $f"; cat -n "$f"; done
fi

echo
echo "== questionnaire serializer/view files =="
rg -n "class .*Questionnaire|QuestionnaireSerializer|LimitOffsetPagination|DEFAULT_PAGINATION_CLASS|questionnaire|limit=100|offset" -S --glob '*.py' . | head -300

Repository: ohcnetwork/care_fe

Length of output: 10137


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== questionnaire helper usage and create helpers =="
cat -n tests/helper/questionnaire.ts
echo
rg -n "getQuestionnaireIdBySlug|createQuestionnaire|PAGINATION_TITLE_PREFIX|PAGINATION_FIXTURE_COUNT|fixture questionnaire|Date\.now\(|faker" tests -S | head -400

echo
echo "== search Django/Questionnaire serializer/view =="
git ls-files | rg 'questionnaires?\.py|questionnaire' | head -200
rg -n "class .*Questionnaire|Questionnaire.*Serializer|QuestionnaireViewSet|LIMIT_OFFSET|Pagination|results|next|offset|limit=100" -S --glob '*.py' . | head -400 || true

Repository: ohcnetwork/care_fe

Length of output: 50374


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== all git-tracked questionnaires files =="
git ls-files | rg '(^|/)(questionnaires|questionnaire).*\.py$|questionnaire.*\.py$' | head -200

echo
echo "== exact questionnaires serializer/views with context =="
while IFS= read -r f; do
  if [ ${`#f`} -lt 400 ]; then
    echo "--- $f"
    rg -n "class .*Questionnaire|Questionnaire.*Serializer|QuestionnaireViewSet|DEFAULT_PAGINATION_CLASS|Pagination|pagination|limit=100|offset|results|next" -S "$f" | head -200
  fi
done < <(git ls-files | rg '(^|/)(questionnaires|questionnaire).*\.py$|questionnaire.*\.py$') || true

echo
echo "== exact questionnaires test usages of getQuestionnaireIdBySlug =="
for f in tests/admin/questionnaires/questionnairePermissions.spec.ts tests/admin/questionnaires/questionnaireVersionsDepth.spec.ts; do
  if [ -f "$f" ]; then
    echo "--- $f"
    sed -n '1,140p' "$f" | cat -n
  fi
done

Repository: ohcnetwork/care_fe

Length of output: 199


Page through all questionnaires before declaring the fixture missing.

getQuestionnaireIdBySlug only reads the first 100 results, but the list endpoint supports pagination. In a shared or accumulated E2E environment, a fixture can be on a later page, so this helper can throw while the fixture still exists. Read pages until next is absent or the slug is found.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/helper/questionnaireV2.ts` around lines 38 - 65, Update
getQuestionnaireIdBySlug to follow the questionnaire list endpoint’s pagination,
checking each page’s results for the requested slug and stopping immediately
when found. Continue requesting the next page until next is absent, then throw
the existing not-found error only after all pages have been searched; preserve
the existing response-error handling and cache behavior.

bodhish and others added 2 commits August 2, 2026 01:31
Restores the legacy fill-flow contract the Task-7 simplification dropped:
the person answering a quantity question picks a UCUM unit per answer via
a ValueSetSelect (system-ucum-units), instead of the unit being pinned to
the author's question.unit. The author-configured question.unit is now the
pre-selected default (backend persists `unit` only; `answer_unit` is
dropped by the Question spec, so nothing may read it), a picked unit is
written into the response entry as [{type:"quantity", value, unit: Code}]
exactly as the legacy QuantityQuestion did, and changing the value
preserves the picked unit. Repeats keep one unit per entry via the
positional withEntryAt writes.

The unit picker (and the coding picker for valueset-bound questions)
merges into the bordered input+note row; widths sit on wrapper divs
because the global button[role=combobox]{width:100%} rule overrides any
width utility on the trigger. The builder's unit row is relabeled
"Default Unit" with a hint, keeping its write to question.unit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Preview (kitchen-sink fixture, read-only): the unit picker pre-selects
the seeded default (milligram), entering a value keeps it, and picking a
different unit through the live terminology search sticks while
preserving the value — with a picker-opens fallback annotation if the
sandbox returns no results (it matches display names, so the search term
is "kilogram", not "kg").

New round-trip spec pins the backend contract: author a quantity
question, set a Default Unit, save, full page reload (fresh GET) before
asserting the builder shows it, then an API read asserts unit.system/
code/display round-trip verbatim and that answer_unit stays absent; the
preview picker must seed from that reloaded state. Builder matrix
assertions updated for the merged unit row and the Default Unit
accessible name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 1, 2026 20:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 134 out of 135 changed files in this pull request and generated no new comments.

Suppressed comments (2)

tests/facility/patient/encounter/forms/enableWhen/string.spec.ts:27

  • test.describe.configure({ mode: "default" }) does not force serial execution under fullyParallel: true; it keeps the default (parallel) behavior. To prevent shared-encounter submissions from leaking across tests in this file, this should be mode: "serial".
    tests/facility/patient/encounter/forms/enableWhen/boolean.spec.ts:23
  • test.describe.configure({ mode: "default" }) does not opt out of fullyParallel: true (it keeps the default behavior, which remains parallel). This undermines the comment’s intent and can still allow cross-test leakage via shared encounter submissions.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Large, well-structured PR. The reducer pattern, per-instance Jotai store, dirty-guard on refetch, and the legacy boolean enable_when migration are all done correctly. The save-validation module is a clean, extensible design.

Three issues worth fixing before merge:

  1. Actual bug (VisibilityConditionsCard): group questions are valid targets in the condition picker, but the renderer never records responses for groups → selecting one permanently hides the dependent question with no feedback. Filter type !== "group" from availableTargets.

  2. Performance (CloneQuestionnaireDialog): the Zod schema is reconstructed on every render. Wrap in useMemo.

  3. Docs (QuestionnaireDetailPage): the JSDoc block describing downloadQuestionnaireJson is orphaned above moveElement, leaving downloadQuestionnaireJson undocumented. Functions need reordering.

Everything else — the enable-when evaluation port from the legacy evaluator, the navigation prompt, the optimistic org toggle, the move-into-self guard in the reducer — looks solid. 🔥

Generated by Grumpy PR Reviewer for issue #16618 · 98.3 AIC · ⌖ 6.21 AIC · ⊞ 6.3K

const enableBehavior = question.enable_behavior ?? "all";
const excludedIds = new Set(collectIds(question));
const availableTargets = flattenQuestions(allQuestions).filter(
(candidate) => !excludedIds.has(candidate.id),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: group questions can be selected as visibility targets, permanently hiding the dependent question.

flattenQuestions includes every question, groups included. But initializeResponses skips groups (they have no answers), so responsesAtom never has an entry for a group's id. When the renderer evaluates enableWhen for a condition pointing at a group, response is undefinedevaluateEnableWhen returns false → the question is hidden forever. The author would have no way to know why their question disappeared.

Filter out group questions here:

const availableTargets = flattenQuestions(allQuestions).filter(
  (candidate) =>
    !excludedIds.has(candidate.id) && candidate.type !== "group",
);

const { t } = useTranslation();
const queryClient = useQueryClient();

const cloneSchema = questionnaireBasicSchema(t).pick({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance: schema reconstructed on every render.

questionnaireBasicSchema(t).pick(...) runs a full Zod schema build + pick call on every render cycle of this dialog. Wrap it in useMemo:

const cloneSchema = useMemo(
  () => questionnaireBasicSchema(t).pick({ title: true, slug: true }),
  [t],
);

While you're at it, the defaultValues object in useForm also re-creates every render — same fix applies.

import { VersionsTab } from "./VersionsTab";

/**
* Serializes only the questionnaire *definition* — the same writable field

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation: orphaned JSDoc attached to the wrong function.

The long comment at line 52 describes downloadQuestionnaireJson (data-URI export, created_by exclusion, etc.) but it sits directly above moveElement. The moveElement JSDoc at line 60 then overrides it as the actual attached doc. downloadQuestionnaireJson at line 70 ends up with no JSDoc at all.

Swap the declaration order — move downloadQuestionnaireJson before moveElement, or at least move the JSDoc comment to sit directly above the function it describes.

bodhish and others added 11 commits August 2, 2026 02:05
…he renderer

Verified legacy contract (develop QuestionnaireEditor/QuestionLabel):
question.unit exists on integer/decimal/choice/quantity and renders as a
({code}) suffix next to the question label; number inputs never had an
answer-time unit picker.

Quantity per owner UX directive: answer_value_set is the unit-choice
source. A bounded expansion (<=8 codes via the terminology $expand,
cached one-shot per valueset) renders every unit as a visible ChoiceChip
with the author's default pre-selected; larger or unexpandable sets keep
the search popover, now scoped to the question's valueset (global UCUM
only as fallback). The legacy renderer's answer_value_set-driven 'Type'
coding picker is removed — saved coding values still pass through
response entries untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… for number and choice types

Legacy contract (develop QuestionnaireEditor): the Custom/ValueSet mode
switcher only ever rendered for choice — quantity always configured its
answers through SelectOrCreateValueset. The v2 AnswerOptionsEditor
wrongly offered a Custom Options table for quantity; it now renders a
valueset-only Unit Options section with a live chips preview of what
answerers will see and the existing Default Unit row. Picking a valueset
actively clears grandfathered answer_option data.

Integer/decimal/choice get the legacy unit row (UNIT_TYPES parity):
a UCUM ValueSetSelect writing question.unit, surfaced next to the
question label in fill mode.

Save guard: the backend rejects quantity questions carrying neither
answer_option nor answer_value_set (Question spec), so the builder now
blocks that save with a clear message instead of a raw 400 —
grandfathered custom-option quantities still pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: c3ea2537e36c
…ecs go valueset-only

New questionnaireUnitSemantics spec: e2e-units fixture preview (integer
/min and decimal Cel label suffixes; bounded e2e-dose-units quantity
renders mg/g/kg as inline chips with the default pre-selected, picking a
chip writes the unit, no popover offered), integer+decimal unit
round-trip through save + hard reload + fresh API GET, and the
client-side quantity-needs-valueset save guard.

Updated quantity authoring specs to the valueset-only contract: assert
Custom Options mode is NOT offered for quantity, author via the UCUM
Units valueset, and pin answer_value_set (and empty answer_option) in
the API round-trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- globalSetup now refreshes tokens for EVERY .auth storage state, not
  just user.json — with --no-deps the setup project never runs, so a
  stale nurse token failed all five permission specs once the JWT aged
  out while admin specs kept passing.
- Nurse permission specs re-verified against a freshly seeded backend
  (migrate + fixtures + questionnaire E2E script): the nurse reads the
  org-tagged fixture questionnaires in the facility list (the old
  'heigh-weight' slug only existed in a stale dev DB), and the admin
  mount lists none of them. Assertions updated to that deterministic
  state.
- New pickValuesetFromAutocomplete helper scopes the valueset search to
  the opened popover/dialog — a bare [data-slot=command-input] first()
  can resolve to a different cmdk input on the builder page, leaving the
  list unfiltered (observed flake).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the cramped Versions-tab dialog with a dedicated read-only page at
{basePath}/{id}/versions/{revisionId} (both mounts) so a past revision
renders with the renderer's full layout. Back returns to the Versions tab
via ?tab=versions, now honored by the detail page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: 9a5977bd3edd
…flow

Open now navigates to the revision page: assert past-revision indicator,
old content with disabled inputs, and Back landing on the Versions tab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…up/display questions

The renderer never records responses for group questions
(initializeResponses skips them) and DisplayText never writes values, so
an enable_when condition targeting either evaluates to "unanswered"
forever and permanently hides the dependent question with zero feedback.

- The target picker excludes non-response-producing types (group,
  display). Structured questions stay eligible: they DO record values
  (StructuredQuestionSlot writes through updateResponse), matching the
  legacy editor which offered every non-group question as a target.
- Legacy/imported data already targeting a group/display surfaces in the
  builder as an invalid (error-styled, disabled) target with an inline
  hint, and a new save-time check blocks the save until it's retargeted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
questionnaireBasicSchema builds fresh Zod objects on every call, and all
three consumers (clone dialog .pick, detail page as-is, create page
.extend) called it inline in the component body — reallocating the whole
schema chain every render. Each now wraps the call in useMemo(..., [t]),
and the schema module's doc states the pattern for future consumers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The export-serialization doc block sat above moveElement, leaving
downloadQuestionnaireJson undocumented and moveElement double-documented.
Pure reorder: moveElement (with its own doc) now precedes the download
doc + function. No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The editor card's header strip (ordinal + title text + "Question Type"
badge) duplicated the Title/Type fields directly below it. The fields now
lead instead: the rich type picker sits first with the kebab menu aligned
top-right, followed by a roomy borderless title input (ordinal kept as a
small prefix, focus underline, visually-hidden label so the "Question
Title" textbox name is unchanged for a11y and specs). Every field below
is untouched. Intentional divergence from the Figma header strip, per
owner direction. The now-unlabeled type picker carries an explicit
aria-label.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: 22687d8f9cfa
Each nav row now shows the question's small tinted type icon between the
ordinal and the title, in both the builder nav and the renderer preview
nav (same shared component). The type→icon+tint map moves from
builder/QuestionTypePicker.tsx to shared/questionTypeIcons.ts because
shared/QuestionTreeNav renders it and shared must not import from
builder/; the picker now consumes the shared map. Icons are decorative
(aria-hidden), so row accessible names are unchanged. The mobile Select
fallback stays text-only. README pointer updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 1, 2026 21:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 139 out of 140 changed files in this pull request and generated no new comments.

Suppressed comments (4)

tests/facility/patient/encounter/forms/enableWhen/string.spec.ts:27

  • test.describe.configure({ mode: "default" }) does not opt this file out of fullyParallel: true, so these stateful tests can still run in parallel and leak shared encounter responses. Use mode: "serial" to guarantee sequential execution.
    tests/support/questionnaireId.ts:12
  • The docstring says the setup auto-runs when the meta file is "missing or invalid", but the implementation only auto-runs when the file is missing (invalid JSON throws). Either implement the retry-on-invalid behavior or update the comment to match actual behavior.
    tests/facility/patient/encounter/forms/enableWhen/boolean.spec.ts:23
  • test.describe.configure({ mode: "default" }) does not opt this file out of fullyParallel: true, so these stateful tests can still run in parallel and leak shared encounter responses. Use mode: "serial" (as used elsewhere in the test suite) to guarantee sequential execution.
    src/Routers/routes/adminRoutes.tsx:30
  • The PR description currently contains placeholder text (e.g. "Fixes #issue_number", "Change 1/2"), but this diff introduces substantial questionnaire/v2 routing & management changes. Please update the PR description to accurately summarize the changes and link the real issue/requirement for traceability.
  "/admin/questionnaires": () => (
    <QuestionnaireListPage scope={INSTANCE_SCOPE} />
  ),

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Questionnaire v2 Builder & Renderer

140 files, 13k+ additions — this is a big chunk of work. The architecture is reasonably thought-out: the reducer-based builder state, Jotai store for renderer, and the shared questionTree.ts utilities are solid foundations. The detailed inline comments throughout (especially the normalizeBooleanConditionAnswer explanation and the useState vs useMemo justification in RendererContext) are genuinely good — the reasoning is written down where it belongs.

Issues (inline comments above):

  1. Firefox download bug (QuestionnaireDetailPage.tsx:81) — anchor click without DOM attachment. Breaks silently in Firefox. Fix before shipping.
  2. Missing memoization (VisibilityConditionsCard.tsx:96) — flattenQuestions rebuilt every render; extract to shared util and memoize.
  3. errorsAtom has no writer (store.ts:23) — intentional per the comment, but QA needs to be explicitly told validation errors won't appear in preview/fill until the submit path lands.
  4. migrateLegacyBooleanEnableWhen always allocates (builderReducer.ts:72) — new array references on every reset means previewQuestionnaire useMemo always invalidates, wiping preview state on background refetches.

The Firefox download bug is the only hard blocker. The rest are code quality issues that should be addressed but won't necessarily break users (on Chrome, anyway).

Generated by Grumpy PR Reviewer for issue #16618 · 69.1 AIC · ⌖ 8.77 AIC · ⊞ 6.3K

const linkElement = document.createElement("a");
linkElement.setAttribute("href", dataUri);
linkElement.setAttribute("download", `${questionnaire.slug}.json`);
linkElement.click();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This silently fails in Firefox. An anchor element not attached to the DOM can't be programmatically clicked in Firefox — you'll get zero download, zero error, just confusion.

document.body.appendChild(linkElement);
linkElement.click();
document.body.removeChild(linkElement);

Alternatively use URL.createObjectURL with a Blob. Either way, test in Firefox before shipping this.

const enableWhen = question.enable_when ?? [];
const enableBehavior = question.enable_behavior ?? "all";
const excludedIds = new Set(collectIds(question));
const flatQuestions = flattenQuestions(allQuestions);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flattenQuestions(allQuestions) runs on every render. For a questionnaire with 50 questions this is cheap, but there's already a findFirstQuestion tree-walker in shared/questionTree.ts. This is also a good candidate to extract there and wrap in useMemo here. As written, the whole flat array is rebuilt every time the parent re-renders.


export const questionnaireAtom = atom<QuestionnaireRead | null>(null);
export const responsesAtom = atom<Record<string, QuestionnaireResponse>>({});
export const errorsAtom = atom<QuestionValidationError[]>([]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

errorsAtom has readers (QuestionField, StructuredQuestionSlot) but no writer in this PR. The store comment explains this is intentional — fill/submit is coming later. That's fine architecture-wise, but those readers will see an empty array forever and silently never show validation errors. Make sure the incomplete submit path is understood by QA so they don't mark "validation working" when it isn't yet.

* boolean answers at evaluation time so already-saved questionnaires work
* without a re-save.
*/
export function migrateLegacyBooleanEnableWhen(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

migrateLegacyBooleanEnableWhen calls mapTree which unconditionally builds new arrays via .map() — so reset always hands a fresh array reference to the state even when zero questions needed migration. Not a correctness bug, but it means previewQuestionnaire will be rebuilt by useMemo on every background refetch, wiping in-progress preview answers. The useMemo deps include state.questions, which will be a new reference after every reset. Worth an identity-preserving short-circuit: if no questions matched needsMigration, return the original array.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

Conflicts have been detected against the base branch. Please merge the base branch into your branch.
cc: @bodhish

See: https://docs.ohc.network/docs/contributing#how-to-resolve-merge-conflicts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Merge Conflict pull requests with merge conflict needs review needs testing Type Changes Contains changes in typescript types

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants