diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d4dfffaa3..0331083496 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - 5432:5432 redis: - image: redis:7.4.1 + image: redis:7.4.2 ports: - 6379:6379 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2652040eea..017e65ff62 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -41,7 +41,7 @@ repos: html, ] - repo: https://github.com/scop/pre-commit-shfmt - rev: v3.10.0-1 + rev: v3.10.0-2 hooks: - id: shfmt - repo: https://github.com/adrienverge/yamllint.git @@ -74,7 +74,7 @@ repos: - ".*/generated/" additional_dependencies: ["gibberish-detector"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.7.2" + rev: "v0.9.1" hooks: - id: ruff-format - id: ruff diff --git a/RELEASE.rst b/RELEASE.rst index 2e5f49429c..79fa1b5afa 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,23 @@ Release Notes ============= +Version 0.28.0 +-------------- + +- remove two unused entries (#1977) +- Update dependency litellm to v1.59.0 (#1972) +- Update dependency ruff to v0.9.2 (#1973) +- Update dependency bpython to ^0.25 (#1971) +- Update Node.js to v22.13.0 (#1970) +- Update redis Docker tag to v7.4.2 (#1969) +- [pre-commit.ci] pre-commit autoupdate (#1820) +- fix line clamping of HTML description (#1967) +- Onboarding Accessibility Improvements (#1960) +- add proper mocking in drawer v2 tests handling items queries if resource is a program (#1965) +- add "courses in program" carousel (#1964) +- Update dependency @storybook/addon-webpack5-compiler-swc to v2 (#1942) +- Update dependency Django to v4.2.18 [SECURITY] (#1963) + Version 0.27.0 (Released January 16, 2025) -------------- diff --git a/ai_chat/factories.py b/ai_chat/factories.py index af9509947a..55fcd2b6e8 100644 --- a/ai_chat/factories.py +++ b/ai_chat/factories.py @@ -11,8 +11,8 @@ class ChatMessageFactory(factory.Factory): role = FuzzyChoice(MessageRole.USER, MessageRole.ASSISTANT) content = factory.Faker("sentence") - id = name = factory.Sequence(lambda n: "%d" % n) - index = factory.Sequence(lambda n: "%d" % n) + id = name = factory.Sequence(lambda n: str(n)) + index = factory.Sequence(lambda n: str(n)) class Meta: model = ChatMessage diff --git a/authentication/middleware.py b/authentication/middleware.py index a9c75facb1..e2c54c7283 100644 --- a/authentication/middleware.py +++ b/authentication/middleware.py @@ -38,7 +38,7 @@ def process_exception(self, request, exception): if url: url += ( - "?" in url and "&" or "?" + ("?" in url and "&") or "?" ) + f"message={quote(message)}&backend={backend_name}" return redirect(url) return None diff --git a/channels/serializers_test.py b/channels/serializers_test.py index 2eb8882537..fd3068f2bd 100644 --- a/channels/serializers_test.py +++ b/channels/serializers_test.py @@ -155,7 +155,7 @@ def test_create_channel(base_channel_data, channel_detail, channel_type): """ paths = sorted( (p.learning_resource for p in LearningPathFactory.create_batch(2)), - key=lambda list: list.id, # noqa: A002 + key=lambda lst: lst.id, reverse=True, ) diff --git a/conftest.py b/conftest.py index c569bd3fce..c0ce4157ce 100644 --- a/conftest.py +++ b/conftest.py @@ -11,7 +11,7 @@ @pytest.fixture(autouse=True) -def prevent_requests(mocker, request): # noqa: PT004 +def prevent_requests(mocker, request): """Patch requests to error on request by default""" if "mocked_responses" in request.fixturenames: return diff --git a/docker-compose.apps.yml b/docker-compose.apps.yml index 81abf6db27..8421517e13 100644 --- a/docker-compose.apps.yml +++ b/docker-compose.apps.yml @@ -30,7 +30,7 @@ services: profiles: - frontend working_dir: /src - image: node:22.12 + image: node:22.13 entrypoint: ["/bin/sh", "-c"] command: - | diff --git a/docker-compose.services.yml b/docker-compose.services.yml index 0469195e48..83b1c9c658 100644 --- a/docker-compose.services.yml +++ b/docker-compose.services.yml @@ -23,7 +23,7 @@ services: redis: profiles: - backend - image: redis:7.4.1 + image: redis:7.4.2 healthcheck: test: ["CMD", "redis-cli", "ping", "|", "grep", "PONG"] interval: 3s diff --git a/fixtures/aws.py b/fixtures/aws.py index a0967415ea..c724a18322 100644 --- a/fixtures/aws.py +++ b/fixtures/aws.py @@ -10,13 +10,13 @@ @pytest.fixture(autouse=True) -def silence_s3_logging(): # noqa: PT004 +def silence_s3_logging(): """Only show S3 errors""" logging.getLogger("botocore").setLevel(logging.ERROR) @pytest.fixture -def mock_s3_fixture(): # noqa: PT004 +def mock_s3_fixture(): """Mock the S3 fixture for the duration of the test""" with mock_aws(): yield diff --git a/fixtures/common.py b/fixtures/common.py index 5da3065c65..8b86e3d7d6 100644 --- a/fixtures/common.py +++ b/fixtures/common.py @@ -22,13 +22,13 @@ @pytest.fixture(autouse=True) -def silence_factory_logging(): # noqa: PT004 +def silence_factory_logging(): """Only show factory errors""" logging.getLogger("factory").setLevel(logging.ERROR) @pytest.fixture(autouse=True) -def warnings_as_errors(): # noqa: PT004 +def warnings_as_errors(): """ Convert warnings to errors. This should only affect unit tests, letting pylint and other plugins raise DeprecationWarnings without erroring. @@ -53,7 +53,7 @@ def warnings_as_errors(): # noqa: PT004 @pytest.fixture -def randomness(): # noqa: PT004 +def randomness(): """Ensure a fixed seed for factoryboy""" factory.fuzzy.reseed_random("happy little clouds") @@ -95,7 +95,7 @@ def mocked_responses(): @pytest.fixture -def offeror_featured_lists(): # noqa: PT004 +def offeror_featured_lists(): """Generate featured offeror lists for testing""" for offered_by in OfferedBy.names(): offeror = LearningResourceOfferorFactory.create(code=offered_by) diff --git a/frontends/api/src/hooks/learningResources/keyFactory.ts b/frontends/api/src/hooks/learningResources/keyFactory.ts index 551e44ee80..7a2f702cb1 100644 --- a/frontends/api/src/hooks/learningResources/keyFactory.ts +++ b/frontends/api/src/hooks/learningResources/keyFactory.ts @@ -8,6 +8,7 @@ import { schoolsApi, featuredApi, } from "../../clients" +import axiosInstance from "../../axios" import type { LearningResource, LearningResourcesApiLearningResourcesListRequest as LearningResourcesListRequest, @@ -16,6 +17,8 @@ import type { OfferorsApiOfferorsListRequest, PlatformsApiPlatformsListRequest, FeaturedApiFeaturedListRequest as FeaturedListParams, + LearningResourcesApiLearningResourcesItemsListRequest as ItemsListRequest, + PaginatedLearningResourceRelationshipList, } from "../../generated/v1" const shuffle = ([...arr]) => { @@ -63,13 +66,34 @@ export const clearListMemberships = (resource: LearningResource) => ({ const learningResources = createQueryKeys("learningResources", { detail: (id: number) => ({ - queryKey: [id], + queryKey: ["detail", id], queryFn: async () => { const { data } = await learningResourcesApi.learningResourcesRetrieve({ id, }) return clearListMemberships(data) }, + contextQueries: { + items: (itemsP: ItemsListRequest) => ({ + queryKey: [itemsP], + queryFn: async ({ pageParam }: { pageParam?: string } = {}) => { + // Use generated API for first request, then use next parameter + const request = pageParam + ? axiosInstance.request({ + method: "get", + url: pageParam, + }) + : learningResourcesApi.learningResourcesItemsList(itemsP) + const { data } = await request + return { + ...data, + results: data.results.map((relation) => ({ + ...clearListMemberships(relation.resource), + })), + } + }, + }), + }, }), list: (params: LearningResourcesListRequest) => ({ queryKey: [params], diff --git a/frontends/api/src/test-utils/urls.ts b/frontends/api/src/test-utils/urls.ts index e7e3716c03..448ddf6bed 100644 --- a/frontends/api/src/test-utils/urls.ts +++ b/frontends/api/src/test-utils/urls.ts @@ -78,6 +78,8 @@ const learningResources = { `${API_BASE_URL}/api/v1/learning_resources/${query(params)}`, details: (params: Params) => `${API_BASE_URL}/api/v1/learning_resources/${params.id}/`, + items: (params: Params) => + `${API_BASE_URL}/api/v1/learning_resources/${params.id}/items/`, featured: (params?: Params) => `${API_BASE_URL}/api/v1/featured/${query(params)}`, similar: (params: Params) => diff --git a/frontends/main/src/app-pages/OnboardingPage/OnboardingPage.test.tsx b/frontends/main/src/app-pages/OnboardingPage/OnboardingPage.test.tsx index baa7c26491..3b40667b6d 100644 --- a/frontends/main/src/app-pages/OnboardingPage/OnboardingPage.test.tsx +++ b/frontends/main/src/app-pages/OnboardingPage/OnboardingPage.test.tsx @@ -52,7 +52,10 @@ const STEP_TITLES = [ "Are you seeking a certificate?", "What is your current level of education?", "What course format are you interested in?", -] +].map((title, index) => ({ + title, + step: index, +})) const PROFILES_FOR_STEPS = times(STEPS_DATA.length, profileForStep) @@ -87,166 +90,66 @@ const queryBackButton = () => screen.queryByRole("button", { name: "Back" }) const queryFinishButton = () => screen.queryByRole("button", { name: "Finish" }) describe("OnboardingPage", () => { - describe("Topic Interests step", () => { - const STEP = 0 - const TITLE = STEP_TITLES[STEP] - - beforeEach(async () => { - await setupAndProgressToStep(STEP) - }) - - test(`Title should be '${TITLE}'`, async () => { - expect(await screen.findByText(TITLE, { exact: false })).not.toBeNil() - }) - - test("Has 'Next' but not 'Back' or 'Finish' buttons", async () => { - const backButton = queryBackButton() - const nextButton = await findNextButton() - const finishButton = queryFinishButton() - - expect(backButton).toBeNil() - expect(nextButton).not.toBeNil() - expect(finishButton).toBeNil() - }) - }) - - describe("Goals step", () => { - const STEP = 1 - const TITLE = STEP_TITLES[STEP] - - beforeEach(async () => { - await setupAndProgressToStep(STEP) - }) - - test(`Title should be '${TITLE}'`, async () => { - expect(await screen.findByText(TITLE, { exact: false })).not.toBeNil() - }) - - test("Has 'Next' and 'Back' buttons", async () => { - const backButton = await findBackButton() - const nextButton = await findNextButton() - const finishButton = queryFinishButton() - - expect(backButton).not.toBeNil() - expect(nextButton).not.toBeNil() - expect(finishButton).toBeNil() - }) - - test("Back button should go to previous step", async () => { - const backButton = await findBackButton() - - await user.click(backButton) - - await waitFor(async () => { - expect( - await screen.findByText(STEP_TITLES[STEP - 1], { exact: false }), - ).not.toBeNil() - }) - }) - }) - - describe("Certificate step", () => { - const STEP = 2 - const TITLE = STEP_TITLES[STEP] - - beforeEach(async () => { - await setupAndProgressToStep(STEP) - }) - - test(`Title should be '${TITLE}'`, async () => { - expect(await screen.findByText(TITLE, { exact: false })).not.toBeNil() - }) - - test("Has 'Next' and 'Back' buttons", async () => { - const backButton = await findBackButton() - const nextButton = await findNextButton() - const finishButton = queryFinishButton() - - expect(backButton).not.toBeNil() - expect(nextButton).not.toBeNil() - expect(finishButton).toBeNil() - }) - - test("Back button should go to previous step", async () => { - const backButton = await findBackButton() - - await user.click(backButton) - - await waitFor(async () => { - expect( - await screen.findByText(STEP_TITLES[STEP - 1], { exact: false }), - ).not.toBeNil() + test.each(STEP_TITLES)( + "Has expected title (step: $step)", + async ({ step, title }) => { + await setupAndProgressToStep(step) + const heading = await screen.findByRole("heading", { + name: new RegExp(title), }) - }) - }) - - describe("Current education step", () => { - const STEP = 3 - const TITLE = STEP_TITLES[STEP] + expect(heading).toBeInTheDocument() + }, + ) + + test.each(STEP_TITLES)( + "Navigation to next step (start: $step)", + async ({ step }) => { + const nextStep = step + 1 + await setupAndProgressToStep(step) + if (step === STEP_TITLES.length - 1) { + await findFinishButton() + expect(queryBackButton()).not.toBeNil() + return + } - beforeEach(async () => { - await setupAndProgressToStep(STEP) - }) - - test(`Title should be '${TITLE}'`, async () => { - expect(await screen.findByText(TITLE, { exact: false })).not.toBeNil() - }) - - test("Has 'Next' and 'Back' buttons", async () => { - const backButton = await findBackButton() const nextButton = await findNextButton() - const finishButton = queryFinishButton() + expect(!!queryBackButton()).toBe(step !== 0) + expect(queryFinishButton()).toBeNil() + + await user.click(nextButton) + + // "Next" button should focus the form so its title is read + const form = screen.getByRole("form") + await waitFor(() => expect(form).toHaveFocus()) + expect(form).toHaveAccessibleName( + expect.stringContaining(STEP_TITLES[nextStep].title), + ) + }, + ) + + test.each(STEP_TITLES)( + "Navigation to prev step (start: $step)", + async ({ step }) => { + const prevStep = step - 1 + await setupAndProgressToStep(step) + if (step === 0) { + await findNextButton() + expect(queryBackButton()).toBeNil() + expect(queryFinishButton()).toBeNil() + return + } - expect(backButton).not.toBeNil() - expect(nextButton).not.toBeNil() - expect(finishButton).toBeNil() - }) - - test("Back button should go to previous step", async () => { const backButton = await findBackButton() - + expect(!!queryNextButton()).toBe(step !== STEPS_DATA.length - 1) + expect(!!queryFinishButton()).toBe(step === STEPS_DATA.length - 1) await user.click(backButton) - await waitFor(async () => { - expect( - await screen.findByText(STEP_TITLES[STEP - 1], { exact: false }), - ).not.toBeNil() - }) - }) - }) - - describe("Learning format step", () => { - const STEP = 4 - const TITLE = STEP_TITLES[STEP] - - beforeEach(async () => { - await setupAndProgressToStep(STEP) - }) - - test(`Title should be '${TITLE}'`, async () => { - expect(await screen.findByText(TITLE, { exact: false })).not.toBeNil() - }) - - test("Has 'Next' and 'Finish' buttons", async () => { - const backButton = await findBackButton() - const nextButton = queryNextButton() - const finishButton = await findFinishButton() - - expect(backButton).not.toBeNil() - expect(nextButton).toBeNil() - expect(finishButton).not.toBeNil() - }) - - test("Back button should go to previous step", async () => { - const backButton = await findBackButton() - - await user.click(backButton) - - await waitFor(async () => { - expect( - await screen.findByText(STEP_TITLES[STEP - 1], { exact: false }), - ).not.toBeNil() - }) - }) - }) + // "Prev" button should focus the form so its title is read + const form = screen.getByRole("form") + await waitFor(() => expect(form).toHaveFocus()) + expect(form).toHaveAccessibleName( + expect.stringContaining(STEP_TITLES[prevStep].title), + ) + }, + ) }) diff --git a/frontends/main/src/app-pages/OnboardingPage/OnboardingPage.tsx b/frontends/main/src/app-pages/OnboardingPage/OnboardingPage.tsx index 43a2ca0274..e81adf5e55 100644 --- a/frontends/main/src/app-pages/OnboardingPage/OnboardingPage.tsx +++ b/frontends/main/src/app-pages/OnboardingPage/OnboardingPage.tsx @@ -1,8 +1,7 @@ "use client" -import React, { useId, useMemo } from "react" +import React, { useEffect, useId, useMemo } from "react" import { useRouter } from "next-nprogress-bar" -import range from "lodash/range" import { styled, Step, @@ -18,6 +17,7 @@ import { RadioChoiceBoxField, SimpleSelectField, Skeleton, + VisuallyHidden, } from "ol-components" import { RiArrowRightLine, RiArrowLeftLine } from "@remixicon/react" @@ -189,10 +189,16 @@ const OnboardingPage: React.FC = () => { value: topic.id.toString(), })) ?? [] + useEffect(() => { + document.getElementById(formId)?.focus() + }, [activeStep, formId]) + if (!profile) { return null } + const formLabelId = `${formId}-label` + const pages = [ { {userLoading ? ( ) : ( - + <Title component="h2" variant="h4" id={formLabelId}> Welcome{user?.first_name ? `, ${user.first_name}` : ""}! What are you interested in learning about? @@ -223,7 +229,7 @@ const OnboardingPage: React.FC = () => { {...GridStyle()} label={