From b5cad0b134c46f80ed992c73b9aeb9e63426182a Mon Sep 17 00:00:00 2001 From: Charlie Nino <83518939+Chalayyy@users.noreply.github.com> Date: Tue, 26 Nov 2024 09:41:27 -0800 Subject: [PATCH 01/26] WB-1804 Use h2 in ModalHeader (#2369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: Per [this conversation](https://khanacademy.slack.com/archives/C8Z9DSKC7/p1732258953822829), we want to update the headers in modals to use `h2` instead of the default `h3` that comes with `HeadingMedium`s Issue: WB-1804 (Related: AX-340) ## Test plan: - [x] Run unit and snapshot tests with `yarn test` and` yarn jest -u` - [x] Verify in Storybook that `OnePaneDialog` and `ModalHeader` have `h2` titles instead of `h3` ![image](https://github.com/user-attachments/assets/703b69a2-bcf5-4176-afdb-84b5f6a9ab9f) Author: Chalayyy Reviewers: jandrade, Chalayyy Required Reviewers: Approved By: jandrade Checks: ✅ Chromatic - Get results on regular PRs (ubuntu-latest, 20.x), ✅ Lint / Lint (ubuntu-latest, 20.x), ✅ Test / Test (ubuntu-latest, 20.x, 2/2), ✅ Test / Test (ubuntu-latest, 20.x, 1/2), ✅ Check build sizes (ubuntu-latest, 20.x), ✅ Chromatic - Build on regular PRs / chromatic (ubuntu-latest, 20.x), ✅ Publish npm snapshot (ubuntu-latest, 20.x), ⏭️ Chromatic - Skip on Release PR (changesets), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ gerald, ✅ Prime node_modules cache for primary configuration (ubuntu-latest, 20.x), ✅ Chromatic - Get results on regular PRs (ubuntu-latest, 20.x), ✅ Test / Test (ubuntu-latest, 20.x, 2/2), ✅ Test / Test (ubuntu-latest, 20.x, 1/2), ✅ Lint / Lint (ubuntu-latest, 20.x), ✅ Check build sizes (ubuntu-latest, 20.x), ✅ Chromatic - Build on regular PRs / chromatic (ubuntu-latest, 20.x), ✅ Publish npm snapshot (ubuntu-latest, 20.x), ⏭️ Chromatic - Skip on Release PR (changesets), ✅ Prime node_modules cache for primary configuration (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ⏭️ dependabot, ✅ gerald Pull Request URL: https://github.com/Khan/wonder-blocks/pull/2369 --- .changeset/gentle-moons-prove.md | 5 +++++ .../src/components/__tests__/modal-header.test.tsx | 10 ++++++++++ .../src/components/modal-header.tsx | 1 + 3 files changed, 16 insertions(+) create mode 100644 .changeset/gentle-moons-prove.md diff --git a/.changeset/gentle-moons-prove.md b/.changeset/gentle-moons-prove.md new file mode 100644 index 000000000..306aa8e8e --- /dev/null +++ b/.changeset/gentle-moons-prove.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/wonder-blocks-modal": patch +--- + +Use h2 tag in modal header instead of h3 diff --git a/packages/wonder-blocks-modal/src/components/__tests__/modal-header.test.tsx b/packages/wonder-blocks-modal/src/components/__tests__/modal-header.test.tsx index 6b57fc29f..a25dbce24 100644 --- a/packages/wonder-blocks-modal/src/components/__tests__/modal-header.test.tsx +++ b/packages/wonder-blocks-modal/src/components/__tests__/modal-header.test.tsx @@ -25,6 +25,16 @@ describe("ModalHeader", () => { expect(screen.getByText("Title")).toBeInTheDocument(); }); + test("uses an h2 tag for the title", () => { + // Arrange, Act + render(); + + // Assert + expect( + screen.getByRole("heading", {level: 2, name: "Title"}), + ).toBeInTheDocument(); + }); + test("using only `breadcrumbs` should render the header", () => { // Arrange, Act render( diff --git a/packages/wonder-blocks-modal/src/components/modal-header.tsx b/packages/wonder-blocks-modal/src/components/modal-header.tsx index 066bc8e48..dd1dc37fe 100644 --- a/packages/wonder-blocks-modal/src/components/modal-header.tsx +++ b/packages/wonder-blocks-modal/src/components/modal-header.tsx @@ -124,6 +124,7 @@ export default function ModalHeader(props: Props) { {breadcrumbs} )} Date: Tue, 26 Nov 2024 13:56:45 -0500 Subject: [PATCH 02/26] =?UTF-8?q?[=F0=9F=94=A5AUDIT=F0=9F=94=A5]=20Fix=20n?= =?UTF-8?q?gettext=20not=20returning=20plural=20translations.=20(#2371)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🖍 _This is an audit!_ 🖍 ## Summary: This bug is in prod right now, unfortunately. Some strings are appearing untranslated. Issue: FEI-6007 ## Test plan: This was actually wrong in the tests!! So I fixed the test in the process. Author: jeresig Auditors: #frontend-infra-web, #wonder-blocks Required Reviewers: Approved By: Checks: ✅ Chromatic - Get results on regular PRs (ubuntu-latest, 20.x), ✅ Test / Test (ubuntu-latest, 20.x, 2/2), ✅ Test / Test (ubuntu-latest, 20.x, 1/2), ✅ Lint / Lint (ubuntu-latest, 20.x), ✅ Check build sizes (ubuntu-latest, 20.x), ✅ Chromatic - Build on regular PRs / chromatic (ubuntu-latest, 20.x), ✅ Publish npm snapshot (ubuntu-latest, 20.x), ⏭️ Chromatic - Skip on Release PR (changesets), ✅ Prime node_modules cache for primary configuration (ubuntu-latest, 20.x), ⏭️ dependabot, ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ gerald Pull Request URL: https://github.com/Khan/wonder-blocks/pull/2371 --- .changeset/fresh-vans-love.md | 5 +++++ .../wonder-blocks-i18n/src/functions/__tests__/i18n.test.ts | 2 +- packages/wonder-blocks-i18n/src/functions/i18n.ts | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/fresh-vans-love.md diff --git a/.changeset/fresh-vans-love.md b/.changeset/fresh-vans-love.md new file mode 100644 index 000000000..f74fb57e5 --- /dev/null +++ b/.changeset/fresh-vans-love.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/wonder-blocks-i18n": patch +--- + +Fix ngettext not returning plural translations. diff --git a/packages/wonder-blocks-i18n/src/functions/__tests__/i18n.test.ts b/packages/wonder-blocks-i18n/src/functions/__tests__/i18n.test.ts index 971e025bc..e793f8785 100644 --- a/packages/wonder-blocks-i18n/src/functions/__tests__/i18n.test.ts +++ b/packages/wonder-blocks-i18n/src/functions/__tests__/i18n.test.ts @@ -139,7 +139,7 @@ describe("i18n", () => { const result = ngettext("Singular", "Plural", 0); // Assert - expect(result).toMatchInlineSnapshot(`"Plural"`); + expect(result).toMatchInlineSnapshot(`"arrrr mateys"`); }); it("doNotTranslate should not translate", () => { diff --git a/packages/wonder-blocks-i18n/src/functions/i18n.ts b/packages/wonder-blocks-i18n/src/functions/i18n.ts index cd3899ea0..94cb4db2f 100644 --- a/packages/wonder-blocks-i18n/src/functions/i18n.ts +++ b/packages/wonder-blocks-i18n/src/functions/i18n.ts @@ -210,7 +210,7 @@ export const ngettext: ngettextOverloads = ( typeof singular === "object" ? singular : { - lang: "en", + lang: getLocale(), // We know plural is a string if singular is not a config object messages: [singular, plural as any], }; From ebcadd43a7c79507a2596ddfd5dfa24f45482d09 Mon Sep 17 00:00:00 2001 From: Khan Actions Bot <56267880+khan-actions-bot@users.noreply.github.com> Date: Tue, 26 Nov 2024 13:59:57 -0500 Subject: [PATCH 03/26] Version Packages (#2368) Co-authored-by: github-actions[bot] --- .changeset/afraid-buckets-yell.md | 2 -- .changeset/fresh-vans-love.md | 5 ----- .changeset/gentle-moons-prove.md | 5 ----- .changeset/old-pears-fix.md | 5 ----- packages/wonder-blocks-birthday-picker/CHANGELOG.md | 7 +++++++ packages/wonder-blocks-birthday-picker/package.json | 4 ++-- packages/wonder-blocks-dropdown/CHANGELOG.md | 11 +++++++++++ packages/wonder-blocks-dropdown/package.json | 4 ++-- packages/wonder-blocks-i18n/CHANGELOG.md | 6 ++++++ packages/wonder-blocks-i18n/package.json | 2 +- packages/wonder-blocks-modal/CHANGELOG.md | 6 ++++++ packages/wonder-blocks-modal/package.json | 2 +- packages/wonder-blocks-popover/CHANGELOG.md | 8 ++++++++ packages/wonder-blocks-popover/package.json | 6 +++--- packages/wonder-blocks-tooltip/CHANGELOG.md | 7 +++++++ packages/wonder-blocks-tooltip/package.json | 4 ++-- 16 files changed, 56 insertions(+), 28 deletions(-) delete mode 100644 .changeset/afraid-buckets-yell.md delete mode 100644 .changeset/fresh-vans-love.md delete mode 100644 .changeset/gentle-moons-prove.md delete mode 100644 .changeset/old-pears-fix.md diff --git a/.changeset/afraid-buckets-yell.md b/.changeset/afraid-buckets-yell.md deleted file mode 100644 index a845151cc..000000000 --- a/.changeset/afraid-buckets-yell.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/fresh-vans-love.md b/.changeset/fresh-vans-love.md deleted file mode 100644 index f74fb57e5..000000000 --- a/.changeset/fresh-vans-love.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/wonder-blocks-i18n": patch ---- - -Fix ngettext not returning plural translations. diff --git a/.changeset/gentle-moons-prove.md b/.changeset/gentle-moons-prove.md deleted file mode 100644 index 306aa8e8e..000000000 --- a/.changeset/gentle-moons-prove.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/wonder-blocks-modal": patch ---- - -Use h2 tag in modal header instead of h3 diff --git a/.changeset/old-pears-fix.md b/.changeset/old-pears-fix.md deleted file mode 100644 index 96b0b21f5..000000000 --- a/.changeset/old-pears-fix.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/wonder-blocks-dropdown": minor ---- - -Add `startIcon` prop to Combobox diff --git a/packages/wonder-blocks-birthday-picker/CHANGELOG.md b/packages/wonder-blocks-birthday-picker/CHANGELOG.md index e589bb640..c75070716 100644 --- a/packages/wonder-blocks-birthday-picker/CHANGELOG.md +++ b/packages/wonder-blocks-birthday-picker/CHANGELOG.md @@ -1,5 +1,12 @@ # @khanacademy/wonder-blocks-birthday-picker +## 2.0.91 + +### Patch Changes + +- Updated dependencies [c512e76e] + - @khanacademy/wonder-blocks-dropdown@5.7.0 + ## 2.0.90 ### Patch Changes diff --git a/packages/wonder-blocks-birthday-picker/package.json b/packages/wonder-blocks-birthday-picker/package.json index 79ed48f93..875be89de 100644 --- a/packages/wonder-blocks-birthday-picker/package.json +++ b/packages/wonder-blocks-birthday-picker/package.json @@ -1,6 +1,6 @@ { "name": "@khanacademy/wonder-blocks-birthday-picker", - "version": "2.0.90", + "version": "2.0.91", "design": "v1", "publishConfig": { "access": "public" @@ -15,7 +15,7 @@ "dependencies": { "@babel/runtime": "^7.18.6", "@khanacademy/wonder-blocks-core": "^7.0.1", - "@khanacademy/wonder-blocks-dropdown": "^5.6.3", + "@khanacademy/wonder-blocks-dropdown": "^5.7.0", "@khanacademy/wonder-blocks-icon": "^4.2.0", "@khanacademy/wonder-blocks-layout": "^2.2.2", "@khanacademy/wonder-blocks-tokens": "^2.1.0", diff --git a/packages/wonder-blocks-dropdown/CHANGELOG.md b/packages/wonder-blocks-dropdown/CHANGELOG.md index 5e31dd626..e1587c28a 100644 --- a/packages/wonder-blocks-dropdown/CHANGELOG.md +++ b/packages/wonder-blocks-dropdown/CHANGELOG.md @@ -1,5 +1,16 @@ # @khanacademy/wonder-blocks-dropdown +## 5.7.0 + +### Minor Changes + +- c512e76e: Add `startIcon` prop to Combobox + +### Patch Changes + +- Updated dependencies [b5cad0b1] + - @khanacademy/wonder-blocks-modal@5.1.17 + ## 5.6.3 ### Patch Changes diff --git a/packages/wonder-blocks-dropdown/package.json b/packages/wonder-blocks-dropdown/package.json index 259a816a8..468ce7ce4 100644 --- a/packages/wonder-blocks-dropdown/package.json +++ b/packages/wonder-blocks-dropdown/package.json @@ -1,6 +1,6 @@ { "name": "@khanacademy/wonder-blocks-dropdown", - "version": "5.6.3", + "version": "5.7.0", "design": "v1", "description": "Dropdown variants for Wonder Blocks.", "main": "dist/index.js", @@ -21,7 +21,7 @@ "@khanacademy/wonder-blocks-core": "^7.0.1", "@khanacademy/wonder-blocks-icon": "^4.2.0", "@khanacademy/wonder-blocks-layout": "^2.2.2", - "@khanacademy/wonder-blocks-modal": "^5.1.16", + "@khanacademy/wonder-blocks-modal": "^5.1.17", "@khanacademy/wonder-blocks-pill": "^2.5.3", "@khanacademy/wonder-blocks-search-field": "^2.3.6", "@khanacademy/wonder-blocks-timing": "^5.0.2", diff --git a/packages/wonder-blocks-i18n/CHANGELOG.md b/packages/wonder-blocks-i18n/CHANGELOG.md index 746bd1420..c3375c2ee 100644 --- a/packages/wonder-blocks-i18n/CHANGELOG.md +++ b/packages/wonder-blocks-i18n/CHANGELOG.md @@ -1,5 +1,11 @@ # @khanacademy/wonder-blocks-i18n +## 3.1.3 + +### Patch Changes + +- 5899cbe4: Fix ngettext not returning plural translations. + ## 3.1.2 ### Patch Changes diff --git a/packages/wonder-blocks-i18n/package.json b/packages/wonder-blocks-i18n/package.json index 5199b1cea..a8965d792 100644 --- a/packages/wonder-blocks-i18n/package.json +++ b/packages/wonder-blocks-i18n/package.json @@ -1,6 +1,6 @@ { "name": "@khanacademy/wonder-blocks-i18n", - "version": "3.1.2", + "version": "3.1.3", "design": "v1", "publishConfig": { "access": "public" diff --git a/packages/wonder-blocks-modal/CHANGELOG.md b/packages/wonder-blocks-modal/CHANGELOG.md index 8382dc30b..e2b7b46c3 100644 --- a/packages/wonder-blocks-modal/CHANGELOG.md +++ b/packages/wonder-blocks-modal/CHANGELOG.md @@ -1,5 +1,11 @@ # @khanacademy/wonder-blocks-modal +## 5.1.17 + +### Patch Changes + +- b5cad0b1: Use h2 tag in modal header instead of h3 + ## 5.1.16 ### Patch Changes diff --git a/packages/wonder-blocks-modal/package.json b/packages/wonder-blocks-modal/package.json index fd80e203a..28dd9f07d 100644 --- a/packages/wonder-blocks-modal/package.json +++ b/packages/wonder-blocks-modal/package.json @@ -1,6 +1,6 @@ { "name": "@khanacademy/wonder-blocks-modal", - "version": "5.1.16", + "version": "5.1.17", "design": "v2", "publishConfig": { "access": "public" diff --git a/packages/wonder-blocks-popover/CHANGELOG.md b/packages/wonder-blocks-popover/CHANGELOG.md index 5a9c0a51d..050497ff1 100644 --- a/packages/wonder-blocks-popover/CHANGELOG.md +++ b/packages/wonder-blocks-popover/CHANGELOG.md @@ -1,5 +1,13 @@ # @khanacademy/wonder-blocks-popover +## 3.3.5 + +### Patch Changes + +- Updated dependencies [b5cad0b1] + - @khanacademy/wonder-blocks-modal@5.1.17 + - @khanacademy/wonder-blocks-tooltip@2.5.5 + ## 3.3.4 ### Patch Changes diff --git a/packages/wonder-blocks-popover/package.json b/packages/wonder-blocks-popover/package.json index 928e1e275..953936b51 100644 --- a/packages/wonder-blocks-popover/package.json +++ b/packages/wonder-blocks-popover/package.json @@ -1,6 +1,6 @@ { "name": "@khanacademy/wonder-blocks-popover", - "version": "3.3.4", + "version": "3.3.5", "design": "v1", "publishConfig": { "access": "public" @@ -18,9 +18,9 @@ "@babel/runtime": "^7.18.6", "@khanacademy/wonder-blocks-core": "^7.0.1", "@khanacademy/wonder-blocks-icon-button": "^5.6.2", - "@khanacademy/wonder-blocks-modal": "^5.1.16", + "@khanacademy/wonder-blocks-modal": "^5.1.17", "@khanacademy/wonder-blocks-tokens": "^2.1.0", - "@khanacademy/wonder-blocks-tooltip": "^2.5.4", + "@khanacademy/wonder-blocks-tooltip": "^2.5.5", "@khanacademy/wonder-blocks-typography": "^2.1.16" }, "peerDependencies": { diff --git a/packages/wonder-blocks-tooltip/CHANGELOG.md b/packages/wonder-blocks-tooltip/CHANGELOG.md index e4a04ef11..9f86c049f 100644 --- a/packages/wonder-blocks-tooltip/CHANGELOG.md +++ b/packages/wonder-blocks-tooltip/CHANGELOG.md @@ -1,5 +1,12 @@ # @khanacademy/wonder-blocks-tooltip +## 2.5.5 + +### Patch Changes + +- Updated dependencies [b5cad0b1] + - @khanacademy/wonder-blocks-modal@5.1.17 + ## 2.5.4 ### Patch Changes diff --git a/packages/wonder-blocks-tooltip/package.json b/packages/wonder-blocks-tooltip/package.json index 20b1cfade..3b1aaa452 100644 --- a/packages/wonder-blocks-tooltip/package.json +++ b/packages/wonder-blocks-tooltip/package.json @@ -1,6 +1,6 @@ { "name": "@khanacademy/wonder-blocks-tooltip", - "version": "2.5.4", + "version": "2.5.5", "design": "v1", "publishConfig": { "access": "public" @@ -18,7 +18,7 @@ "@babel/runtime": "^7.18.6", "@khanacademy/wonder-blocks-core": "^7.0.1", "@khanacademy/wonder-blocks-layout": "^2.2.2", - "@khanacademy/wonder-blocks-modal": "^5.1.16", + "@khanacademy/wonder-blocks-modal": "^5.1.17", "@khanacademy/wonder-blocks-tokens": "^2.1.0", "@khanacademy/wonder-blocks-typography": "^2.1.16" }, From 5af2e7516f7cfedacfd162787dcfe3888a2be22b Mon Sep 17 00:00:00 2001 From: John Resig Date: Wed, 27 Nov 2024 11:48:50 -0500 Subject: [PATCH 04/26] =?UTF-8?q?[=F0=9F=94=A5AUDIT=F0=9F=94=A5]=20Fix=20u?= =?UTF-8?q?p=20plural=20form=20fallback=20when=20no=20translation=20is=20p?= =?UTF-8?q?resent.=20(#2374)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🖍 _This is an audit!_ 🖍 ## Summary: I may have introduced this in my last fix, actually - but this now helps us ensure that we don't accidentally use the other language pluralization rules when we are falling back to English strings. Issue: FEI-6007 ## Test plan: The tests pass! Author: jeresig Auditors: #wonder-blocks Required Reviewers: Approved By: Checks: ✅ Chromatic - Get results on regular PRs (ubuntu-latest, 20.x), ✅ Test / Test (ubuntu-latest, 20.x, 2/2), ✅ Test / Test (ubuntu-latest, 20.x, 1/2), ✅ Lint / Lint (ubuntu-latest, 20.x), ✅ Check build sizes (ubuntu-latest, 20.x), ✅ Chromatic - Build on regular PRs / chromatic (ubuntu-latest, 20.x), ⏭️ Chromatic - Skip on Release PR (changesets), ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Prime node_modules cache for primary configuration (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ gerald, ⏭️ dependabot Pull Request URL: https://github.com/Khan/wonder-blocks/pull/2374 --- .changeset/dry-kangaroos-boil.md | 5 ++++ .../functions/__tests__/i18n-store.test.ts | 12 ++++----- .../src/functions/__tests__/i18n.test.ts | 11 ++++++++ .../src/functions/i18n-store.ts | 27 +++++++++++++++++-- .../wonder-blocks-i18n/src/functions/i18n.ts | 25 ++--------------- packages/wonder-blocks-i18n/src/index.ts | 7 +++-- 6 files changed, 54 insertions(+), 33 deletions(-) create mode 100644 .changeset/dry-kangaroos-boil.md diff --git a/.changeset/dry-kangaroos-boil.md b/.changeset/dry-kangaroos-boil.md new file mode 100644 index 000000000..edf264b31 --- /dev/null +++ b/.changeset/dry-kangaroos-boil.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/wonder-blocks-i18n": patch +--- + +Fix a bug with pluralization for fallback untranslated languages. diff --git a/packages/wonder-blocks-i18n/src/functions/__tests__/i18n-store.test.ts b/packages/wonder-blocks-i18n/src/functions/__tests__/i18n-store.test.ts index 603ed8306..4e46e8464 100644 --- a/packages/wonder-blocks-i18n/src/functions/__tests__/i18n-store.test.ts +++ b/packages/wonder-blocks-i18n/src/functions/__tests__/i18n-store.test.ts @@ -84,10 +84,10 @@ describe("getPluralTranslation", () => { // Act const result = getPluralTranslation( { - lang: TEST_LOCALE, + lang: "en", messages: ["test singular", "test plural"], }, - 0, + 1, ); // Assert @@ -104,10 +104,10 @@ describe("getPluralTranslation", () => { // Act const result = getPluralTranslation( { - lang: TEST_LOCALE, + lang: "en", messages: ["test singular", "test plural"], }, - 1, + 2, ); // Assert @@ -135,10 +135,10 @@ describe("getPluralTranslation", () => { // Act const result = getPluralTranslation( { - lang: TEST_LOCALE, + lang: "en", messages: ["test singular", "test plural"], }, - 0, + 1, ); // Assert diff --git a/packages/wonder-blocks-i18n/src/functions/__tests__/i18n.test.ts b/packages/wonder-blocks-i18n/src/functions/__tests__/i18n.test.ts index e793f8785..5fe5bac4e 100644 --- a/packages/wonder-blocks-i18n/src/functions/__tests__/i18n.test.ts +++ b/packages/wonder-blocks-i18n/src/functions/__tests__/i18n.test.ts @@ -142,6 +142,17 @@ describe("i18n", () => { expect(result).toMatchInlineSnapshot(`"arrrr mateys"`); }); + it("ngettext should handle missing translations", () => { + // Arrange + jest.spyOn(Locale, "getLocale").mockImplementation(() => "ru"); + + // Act + const result = ngettext("Singular", "Plural", 0); + + // Assert + expect(result).toMatchInlineSnapshot(`"Plural"`); + }); + it("doNotTranslate should not translate", () => { // Arrange loadTranslations(TEST_LOCALE, { diff --git a/packages/wonder-blocks-i18n/src/functions/i18n-store.ts b/packages/wonder-blocks-i18n/src/functions/i18n-store.ts index ce2e31f7c..159785a96 100644 --- a/packages/wonder-blocks-i18n/src/functions/i18n-store.ts +++ b/packages/wonder-blocks-i18n/src/functions/i18n-store.ts @@ -5,8 +5,11 @@ */ import FakeTranslate from "./i18n-faketranslate"; import {getLocale} from "./locale"; +import {allPluralForms} from "./plural-forms"; import {PluralConfigurationObject} from "./types"; +type Language = keyof typeof allPluralForms; + // The cache of strings that have been translated, by locale. const localeMessageStore = new Map< string, @@ -18,6 +21,20 @@ const localeMessageStore = new Map< // Create a fake translate object to use if we can't find a translation. const {translate: fakeTranslate} = new FakeTranslate(); +/* + * Return the ngettext position that matches the given number and lang. + * + * Arguments: + * - num: The number upon which to toggle the plural forms. + * - lang: The language to use as the basis for the pluralization. + */ +export const ngetpos = function (num: number, lang?: Language): number { + const pluralForm = (lang && allPluralForms[lang]) || allPluralForms["en"]; + const pos = pluralForm(num); + // Map true to 1 and false to 0, keep any numeric return value the same. + return pos === true ? 1 : pos ? pos : 0; +}; + /** * Get the translation for a given id and locale. * @@ -89,12 +106,15 @@ export const getSingularTranslation = ( */ export const getPluralTranslation = ( pluralConfig: PluralConfigurationObject, - idx: number, + num: number, ) => { const {lang, messages} = pluralConfig; // We try to find the translation in the cache. - const translatedMessages = getTranslationFromStore(messages[0], lang); + const translatedMessages = getTranslationFromStore( + messages[0], + getLocale(), + ); // We found the translation so we can return the right plural form. if (translatedMessages) { @@ -103,10 +123,13 @@ export const getPluralTranslation = ( // just in case. return translatedMessages; } + // Get the translated string + const idx = ngetpos(num, getLocale()); return translatedMessages[idx]; } // Otherwise, there's no translation, so we try to do fake translation. + const idx = ngetpos(num, lang); return fakeTranslate(messages[idx]); }; diff --git a/packages/wonder-blocks-i18n/src/functions/i18n.ts b/packages/wonder-blocks-i18n/src/functions/i18n.ts index 94cb4db2f..cb2aafe64 100644 --- a/packages/wonder-blocks-i18n/src/functions/i18n.ts +++ b/packages/wonder-blocks-i18n/src/functions/i18n.ts @@ -3,7 +3,6 @@ /* To fix, remove an entry above, run ka-lint, and fix errors. */ import * as React from "react"; -import {allPluralForms} from "./plural-forms"; import {getLocale} from "./locale"; import {PluralConfigurationObject} from "./types"; import {getPluralTranslation, getSingularTranslation} from "./i18n-store"; @@ -47,8 +46,6 @@ interface _Overloads { ): string; } -type Language = keyof typeof allPluralForms; - const interpolationMarker = /%\(([\w_]+)\)s/g; /** @@ -210,7 +207,7 @@ export const ngettext: ngettextOverloads = ( typeof singular === "object" ? singular : { - lang: getLocale(), + lang: "en", // We know plural is a string if singular is not a config object messages: [singular, plural as any], }; @@ -220,11 +217,7 @@ export const ngettext: ngettextOverloads = ( const actualOptions: NGetOptions = (typeof singular === "object" ? num : (options as any)) || {}; - // Get the translated string - const idx = ngetpos(actualNum, pluralConfObj.lang); - - // The common (non-error) case is messages[idx]. - const translation = getPluralTranslation(pluralConfObj, idx); + const translation = getPluralTranslation(pluralConfObj, actualNum); // Get the options to substitute into the string. // We automatically add in the 'magic' option-variable 'num'. @@ -245,20 +238,6 @@ const formatNumber = (num: number): string => { return Intl.NumberFormat(getLocale()).format(num); }; -/* - * Return the ngettext position that matches the given number and lang. - * - * Arguments: - * - num: The number upon which to toggle the plural forms. - * - lang: The language to use as the basis for the pluralization. - */ -export const ngetpos = function (num: number, lang?: Language): number { - const pluralForm = (lang && allPluralForms[lang]) || allPluralForms["en"]; - const pos = pluralForm(num); - // Map true to 1 and false to 0, keep any numeric return value the same. - return pos === true ? 1 : pos ? pos : 0; -}; - /* * A dummy identity function. It's used as a signal to automatic * translation-identification tools that they shouldn't mark this diff --git a/packages/wonder-blocks-i18n/src/index.ts b/packages/wonder-blocks-i18n/src/index.ts index 2ef40d750..21349ac44 100644 --- a/packages/wonder-blocks-i18n/src/index.ts +++ b/packages/wonder-blocks-i18n/src/index.ts @@ -4,9 +4,12 @@ export { ngettext, doNotTranslate, doNotTranslateYet, // used by handlebars translation functions in webapp - ngetpos, } from "./functions/i18n"; -export {loadTranslations, clearTranslations} from "./functions/i18n-store"; +export { + loadTranslations, + clearTranslations, + ngetpos, +} from "./functions/i18n-store"; export {localeToFixed, getDecimalSeparator} from "./functions/l10n"; export {getLocale, setLocale} from "./functions/locale"; From 1ce2a2e4db0e4d881b7b02d642d2d74d67ee49a8 Mon Sep 17 00:00:00 2001 From: Khan Actions Bot <56267880+khan-actions-bot@users.noreply.github.com> Date: Wed, 27 Nov 2024 11:51:55 -0500 Subject: [PATCH 05/26] Version Packages (#2375) Co-authored-by: github-actions[bot] --- .changeset/dry-kangaroos-boil.md | 5 ----- packages/wonder-blocks-i18n/CHANGELOG.md | 6 ++++++ packages/wonder-blocks-i18n/package.json | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) delete mode 100644 .changeset/dry-kangaroos-boil.md diff --git a/.changeset/dry-kangaroos-boil.md b/.changeset/dry-kangaroos-boil.md deleted file mode 100644 index edf264b31..000000000 --- a/.changeset/dry-kangaroos-boil.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/wonder-blocks-i18n": patch ---- - -Fix a bug with pluralization for fallback untranslated languages. diff --git a/packages/wonder-blocks-i18n/CHANGELOG.md b/packages/wonder-blocks-i18n/CHANGELOG.md index c3375c2ee..d7347a8a4 100644 --- a/packages/wonder-blocks-i18n/CHANGELOG.md +++ b/packages/wonder-blocks-i18n/CHANGELOG.md @@ -1,5 +1,11 @@ # @khanacademy/wonder-blocks-i18n +## 3.1.4 + +### Patch Changes + +- 5af2e751: Fix a bug with pluralization for fallback untranslated languages. + ## 3.1.3 ### Patch Changes diff --git a/packages/wonder-blocks-i18n/package.json b/packages/wonder-blocks-i18n/package.json index a8965d792..ef83f6dfc 100644 --- a/packages/wonder-blocks-i18n/package.json +++ b/packages/wonder-blocks-i18n/package.json @@ -1,6 +1,6 @@ { "name": "@khanacademy/wonder-blocks-i18n", - "version": "3.1.3", + "version": "3.1.4", "design": "v1", "publishConfig": { "access": "public" From 2b8424ca3a015a9b48f6d96c854d800296fa07a9 Mon Sep 17 00:00:00 2001 From: daniellewhyte <30729058+daniellewhyte@users.noreply.github.com> Date: Mon, 2 Dec 2024 14:09:49 -0600 Subject: [PATCH 06/26] Update label in SingleSelect and MultiSelect (#2354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: When you pass in a JSX Element as a label to `OptionItem`, the SelectOpener is labeled with an empty string. This PR updates SelectOpener in the `SingleSelect` and `MultiSelect` components to return the JSX as a label in that case. This change is being made to unblock supporting TEX in the Perseus Dropdown widget. Issue: LIT-1425 ## Test plan: - Added new stories - https://5e1bf4b385e3fb0020b7073c-xhxyrfwkfd.chromatic.com/?path=/story/packages-dropdown-singleselect--custom-option-item-with-node-label - https://5e1bf4b385e3fb0020b7073c-xhxyrfwkfd.chromatic.com/?path=/story/packages-dropdown-multiselect--custom-option-items-with-node-label - Unit tests pass - Installed npm snapshot and tested against my branch in Perseus ([PR here](https://github.com/Khan/perseus/pull/1810)) - Test in webapp and storybook to ensure no regressions Author: daniellewhyte Reviewers: marcysutton, daniellewhyte, beaesguerra, jandrade Required Reviewers: Approved By: beaesguerra, jandrade Checks: ✅ Chromatic - Get results on regular PRs (ubuntu-latest, 20.x), ✅ Test / Test (ubuntu-latest, 20.x, 2/2), ✅ Test / Test (ubuntu-latest, 20.x, 1/2), ✅ Lint / Lint (ubuntu-latest, 20.x), ✅ Check build sizes (ubuntu-latest, 20.x), ✅ Chromatic - Build on regular PRs / chromatic (ubuntu-latest, 20.x), ✅ Publish npm snapshot (ubuntu-latest, 20.x), ⏭️ Chromatic - Skip on Release PR (changesets), ✅ Prime node_modules cache for primary configuration (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ gerald, ⏭️ dependabot Pull Request URL: https://github.com/Khan/wonder-blocks/pull/2354 --- .changeset/thirty-shirts-leave.md | 5 ++ .../multi-select.argtypes.ts | 12 ++++ .../multi-select.stories.tsx | 58 ++++++++++++++++++- .../option-item-examples.tsx | 25 ++++++++ .../single-select.argtypes.ts | 12 ++++ .../single-select.stories.tsx | 58 ++++++++++++++++++- .../__tests__/multi-select.test.tsx | 30 +++++++++- .../__tests__/single-select.test.tsx | 31 +++++++++- .../src/components/multi-select.tsx | 20 +++++-- .../src/components/single-select.tsx | 13 ++++- .../src/util/__tests__/helpers.test.tsx | 39 ++++++++++++- .../src/util/helpers.ts | 14 +++++ 12 files changed, 306 insertions(+), 11 deletions(-) create mode 100644 .changeset/thirty-shirts-leave.md diff --git a/.changeset/thirty-shirts-leave.md b/.changeset/thirty-shirts-leave.md new file mode 100644 index 000000000..ac0c90b18 --- /dev/null +++ b/.changeset/thirty-shirts-leave.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/wonder-blocks-dropdown": minor +--- + +Allow use of JSX Element as label in SingleSelect and MultiSelect diff --git a/__docs__/wonder-blocks-dropdown/multi-select.argtypes.ts b/__docs__/wonder-blocks-dropdown/multi-select.argtypes.ts index b4d3fd6dc..517cf9a46 100644 --- a/__docs__/wonder-blocks-dropdown/multi-select.argtypes.ts +++ b/__docs__/wonder-blocks-dropdown/multi-select.argtypes.ts @@ -21,6 +21,18 @@ const argTypes: ArgTypes = { type: {summary: "Labels"}, }, }, + showOpenerLabelAsText: { + control: {type: "boolean"}, + description: `When false, the SelectOpener can show a Node as a label. When true, the + SelectOpener will use a string as a label. If using custom OptionItems, a + plain text label can be provided with the \`labelAsText\` prop. + Defaults to true.`, + + table: { + type: {summary: "boolean"}, + defaultValue: {summary: "true"}, + }, + }, }; export default argTypes; diff --git a/__docs__/wonder-blocks-dropdown/multi-select.stories.tsx b/__docs__/wonder-blocks-dropdown/multi-select.stories.tsx index 95e9e3534..01ed1773c 100644 --- a/__docs__/wonder-blocks-dropdown/multi-select.stories.tsx +++ b/__docs__/wonder-blocks-dropdown/multi-select.stories.tsx @@ -18,7 +18,12 @@ import ComponentInfo from "../../.storybook/components/component-info"; import packageConfig from "../../packages/wonder-blocks-dropdown/package.json"; import multiSelectArgtypes from "./multi-select.argtypes"; import {defaultLabels} from "../../packages/wonder-blocks-dropdown/src/util/constants"; -import {allCountries, allProfilesWithPictures} from "./option-item-examples"; +import { + allCountries, + allProfilesWithPictures, + locales, + chatIcon, +} from "./option-item-examples"; import {OpenerProps} from "../../packages/wonder-blocks-dropdown/src/util/types"; import Strut from "../../packages/wonder-blocks-layout/src/components/strut"; @@ -650,3 +655,54 @@ export const CustomOptionItems: StoryComponentType = { ), ], }; + +/** + * This example illustrates how a JSX Element can appear as the label by setting + * `showOpenerLabelAsText` to false. Note that in this example, we define + * `labelAsText` on the OptionItems to ensure that filtering works correctly. + */ +export const CustomOptionItemsWithNodeLabel: StoryComponentType = { + render: function Render() { + const [opened, setOpened] = React.useState(true); + const [selectedValues, setSelectedValues] = React.useState< + Array + >([]); + + const handleChange = (selectedValues: Array) => { + setSelectedValues(selectedValues); + }; + + const handleToggle = (opened: boolean) => { + setOpened(opened); + }; + + return ( + + {locales.map((locale, index) => ( + + {chatIcon} {locale} + + } + labelAsText={locale} + /> + ))} + + ); + }, + decorators: [ + (Story): React.ReactElement> => ( + {Story()} + ), + ], +}; diff --git a/__docs__/wonder-blocks-dropdown/option-item-examples.tsx b/__docs__/wonder-blocks-dropdown/option-item-examples.tsx index 5b679945e..465f78218 100644 --- a/__docs__/wonder-blocks-dropdown/option-item-examples.tsx +++ b/__docs__/wonder-blocks-dropdown/option-item-examples.tsx @@ -1,5 +1,10 @@ import * as React from "react"; import userCircleIcon from "@phosphor-icons/core/duotone/user-circle-duotone.svg"; +import chatBubbleIcon from "@phosphor-icons/core/regular/chats.svg"; +import bitcoinIcon from "@phosphor-icons/core/regular/currency-btc.svg"; +import euroIcon from "@phosphor-icons/core/regular/currency-eur.svg"; +import dollarIcon from "@phosphor-icons/core/regular/currency-dollar.svg"; +import yenIcon from "@phosphor-icons/core/regular/currency-jpy.svg"; import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon"; export const allCountries = [ @@ -301,3 +306,23 @@ export const allProfilesWithPictures = [ picture: icon, }, ]; + +export const currencies = [ + {name: "Bitcoin", icon: bitcoinIcon}, + {name: "Dollars", icon: dollarIcon}, + {name: "Yen", icon: yenIcon}, + {name: "Euros", icon: euroIcon}, +]; + +export const locales = [ + "অসমীয়া", + "Azərbaycanca", + "čeština", + "dansk", + "Ελληνικά", + "ગુજરાતી", + "magyar", + "Bahasa Indonesia", +]; + +export const chatIcon = ; diff --git a/__docs__/wonder-blocks-dropdown/single-select.argtypes.ts b/__docs__/wonder-blocks-dropdown/single-select.argtypes.ts index b381ccaac..c2bb94145 100644 --- a/__docs__/wonder-blocks-dropdown/single-select.argtypes.ts +++ b/__docs__/wonder-blocks-dropdown/single-select.argtypes.ts @@ -21,6 +21,18 @@ const argTypes: ArgTypes = { type: {summary: "Labels"}, }, }, + showOpenerLabelAsText: { + control: {type: "boolean"}, + description: `When false, the SelectOpener can show a Node as a label. When true, the + SelectOpener will use a string as a label. If using custom OptionItems, a + plain text label can be provided with the \`labelAsText\` prop. + Defaults to true.`, + + table: { + type: {summary: "boolean"}, + defaultValue: {summary: "true"}, + }, + }, }; export default argTypes; diff --git a/__docs__/wonder-blocks-dropdown/single-select.stories.tsx b/__docs__/wonder-blocks-dropdown/single-select.stories.tsx index 5f74209f1..656470b49 100644 --- a/__docs__/wonder-blocks-dropdown/single-select.stories.tsx +++ b/__docs__/wonder-blocks-dropdown/single-select.stories.tsx @@ -31,7 +31,11 @@ import ComponentInfo from "../../.storybook/components/component-info"; import singleSelectArgtypes from "./single-select.argtypes"; import {IconMappings} from "../wonder-blocks-icon/phosphor-icon.argtypes"; import {defaultLabels} from "../../packages/wonder-blocks-dropdown/src/util/constants"; -import {allCountries, allProfilesWithPictures} from "./option-item-examples"; +import { + allCountries, + allProfilesWithPictures, + currencies, +} from "./option-item-examples"; import {OpenerProps} from "../../packages/wonder-blocks-dropdown/src/util/types"; type StoryComponentType = StoryObj; @@ -884,6 +888,58 @@ export const CustomOptionItems: StoryComponentType = { }, }; +/** + * This example illustrates how a JSX Element can appear as the label if + * `labelAsText` is undefined. Note that in this example, we define `labelAsText` + * on the OptionItems to ensure that filtering works correctly. + */ +export const CustomOptionItemWithNodeLabel: StoryComponentType = { + render: function Render() { + const [opened, setOpened] = React.useState(true); + const [selectedValue, setSelectedValue] = React.useState(""); + + const handleChange = (selectedValue: string) => { + setSelectedValue(selectedValue); + }; + + const handleToggle = (opened: boolean) => { + setOpened(opened); + }; + + return ( + + + {currencies.map((currency, index) => ( + + + {currency.name} + + } + labelAsText={currency.name} + /> + ))} + + + ); + }, +}; + /** * This example illustrates how you can use the `OptionItem` component to * display a virtualized list with custom option items. Note that in this diff --git a/packages/wonder-blocks-dropdown/src/components/__tests__/multi-select.test.tsx b/packages/wonder-blocks-dropdown/src/components/__tests__/multi-select.test.tsx index 2989923bf..b76dc2d5d 100644 --- a/packages/wonder-blocks-dropdown/src/components/__tests__/multi-select.test.tsx +++ b/packages/wonder-blocks-dropdown/src/components/__tests__/multi-select.test.tsx @@ -1,7 +1,13 @@ /* eslint-disable no-constant-condition */ /* eslint-disable max-lines */ import * as React from "react"; -import {fireEvent, render, screen, waitFor} from "@testing-library/react"; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; import { userEvent as ue, PointerEventsCheckLevel, @@ -264,6 +270,28 @@ describe("MultiSelect", () => { // Assert expect(opener).toHaveAttribute("data-testid", "some-test-id"); }); + + it("can render a Node as a label", async () => { + // Arrange + doRender( + + custom item 1} value="1" /> + custom item 2} value="2" /> + custom item 3} value="3" /> + , + ); + + // Act + const opener = await screen.findByRole("button"); + const menuLabel = within(opener).getByText("custom item 1"); + + // Assert + expect(menuLabel).toBeVisible(); + }); }); describe("Controlled component", () => { diff --git a/packages/wonder-blocks-dropdown/src/components/__tests__/single-select.test.tsx b/packages/wonder-blocks-dropdown/src/components/__tests__/single-select.test.tsx index deb11b651..1fd2fbb1b 100644 --- a/packages/wonder-blocks-dropdown/src/components/__tests__/single-select.test.tsx +++ b/packages/wonder-blocks-dropdown/src/components/__tests__/single-select.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable max-lines */ import * as React from "react"; -import {fireEvent, render, screen} from "@testing-library/react"; +import {fireEvent, render, screen, within} from "@testing-library/react"; import { userEvent as ue, PointerEventsCheckLevel, @@ -135,6 +135,35 @@ describe("SingleSelect", () => { // Assert expect(opener).toHaveTextContent("Plain Toggle A"); }); + it("can render a Node as a label", async () => { + // Arrange + doRender( + + custom item A} + value="toggle_a" + labelAsText="Plain Toggle A" + /> + custom item B} + value="toggle_b" + labelAsText="Plain Toggle B" + /> + , + ); + + // Act + const opener = await screen.findByRole("button"); + const menuLabel = within(opener).getByText("custom item A"); + + // Assert + expect(menuLabel).toBeVisible(); + }); }); describe("mouse", () => { diff --git a/packages/wonder-blocks-dropdown/src/components/multi-select.tsx b/packages/wonder-blocks-dropdown/src/components/multi-select.tsx index 2545dc6b3..e8922fe19 100644 --- a/packages/wonder-blocks-dropdown/src/components/multi-select.tsx +++ b/packages/wonder-blocks-dropdown/src/components/multi-select.tsx @@ -25,7 +25,7 @@ import type { OptionItemComponent, OptionItemComponentArray, } from "../util/types"; -import {getLabel} from "../util/helpers"; +import {getLabel, getSelectOpenerLabel} from "../util/helpers"; export type Labels = { /** @@ -91,6 +91,13 @@ type DefaultProps = Readonly<{ * Whether to display shortcuts for Select All and Select None. */ shortcuts: boolean; + /** + * When false, the SelectOpener can show a Node as a label. When true, the + * SelectOpener will use a string as a label. If using custom OptionItems, a + * plain text label can be provided with the `labelAsText` prop. + * Defaults to true. + */ + showOpenerLabelAsText: boolean; }>; type Props = AriaProps & @@ -227,6 +234,7 @@ export default class MultiSelect extends React.Component { light: false, shortcuts: false, selectedValues: [], + showOpenerLabelAsText: true, }; constructor(props: Props) { @@ -315,8 +323,9 @@ export default class MultiSelect extends React.Component { onChange([]); }; - getMenuText(children: OptionItemComponentArray): string { - const {implicitAllEnabled, selectedValues} = this.props; + getMenuText(children: OptionItemComponentArray): string | JSX.Element { + const {implicitAllEnabled, selectedValues, showOpenerLabelAsText} = + this.props; const {noneSelected, someSelected, allSelected} = this.state.labels; const numSelectedAll = children.filter( (option) => !option.props.disabled, @@ -338,7 +347,10 @@ export default class MultiSelect extends React.Component { ); if (selectedItem) { - const selectedLabel = getLabel(selectedItem?.props); + const selectedLabel = getSelectOpenerLabel( + showOpenerLabelAsText, + selectedItem?.props, + ); if (selectedLabel) { return selectedLabel; // If the label is a ReactNode and `labelAsText` is not set, diff --git a/packages/wonder-blocks-dropdown/src/components/single-select.tsx b/packages/wonder-blocks-dropdown/src/components/single-select.tsx index 52cb8be21..c3e31b17e 100644 --- a/packages/wonder-blocks-dropdown/src/components/single-select.tsx +++ b/packages/wonder-blocks-dropdown/src/components/single-select.tsx @@ -22,7 +22,7 @@ import type { OpenerProps, OptionItemComponentArray, } from "../util/types"; -import {getLabel} from "../util/helpers"; +import {getLabel, getSelectOpenerLabel} from "../util/helpers"; export type SingleSelectLabels = { /** @@ -85,6 +85,13 @@ type DefaultProps = Readonly<{ * The object containing the custom labels used inside this component. */ labels: SingleSelectLabels; + /** + * When false, the SelectOpener can show a Node as a label. When true, the + * SelectOpener will use a string as a label. If using custom OptionItems, a + * plain text label can be provided with the `labelAsText` prop. + * Defaults to true. + */ + showOpenerLabelAsText: boolean; }>; type Props = AriaProps & @@ -241,6 +248,7 @@ export default class SingleSelect extends React.Component { noResults: defaultLabels.noResults, someResults: defaultLabels.someSelected, }, + showOpenerLabelAsText: true, }; constructor(props: Props) { @@ -406,6 +414,7 @@ export default class SingleSelect extends React.Component { className, "aria-invalid": ariaInvalid, "aria-required": ariaRequired, + showOpenerLabelAsText, ...sharedProps } = this.props; @@ -418,7 +427,7 @@ export default class SingleSelect extends React.Component { // If nothing is selected, or if the selectedValue doesn't match any // item in the menu, use the placeholder. const menuText = selectedItem - ? getLabel(selectedItem.props) + ? getSelectOpenerLabel(showOpenerLabelAsText, selectedItem.props) : placeholder; const dropdownOpener = ( diff --git a/packages/wonder-blocks-dropdown/src/util/__tests__/helpers.test.tsx b/packages/wonder-blocks-dropdown/src/util/__tests__/helpers.test.tsx index c2d92bdba..3ac0ffd26 100644 --- a/packages/wonder-blocks-dropdown/src/util/__tests__/helpers.test.tsx +++ b/packages/wonder-blocks-dropdown/src/util/__tests__/helpers.test.tsx @@ -1,7 +1,12 @@ import * as React from "react"; import {PropsFor} from "@khanacademy/wonder-blocks-core"; import OptionItem from "../../components/option-item"; -import {debounce, getLabel, getStringForKey} from "../helpers"; +import { + debounce, + getLabel, + getSelectOpenerLabel, + getStringForKey, +} from "../helpers"; describe("getStringForKey", () => { it("should get a valid string", () => { @@ -119,3 +124,35 @@ describe("getLabel", () => { expect(label).toBe(""); }); }); + +describe("getSelectOpenerLabel", () => { + it("should return the label if the label is a Node and showOpenerLabelAsText is true", () => { + // Arrange + const props: PropsFor = { + label:
a custom node
, + labelAsText: undefined, + value: "foo", + }; + + // Act + const label = getSelectOpenerLabel(false, props); + + // Assert + expect(label).toStrictEqual(
a custom node
); + }); + + it("should return a string if the label is a Node and showOpenerLabelAsText is false", () => { + // Arrange + const props: PropsFor = { + label:
a custom node
, + labelAsText: "plain text", + value: "foo", + }; + + // Act + const label = getSelectOpenerLabel(true, props); + + // Assert + expect(label).toBe("plain text"); + }); +}); diff --git a/packages/wonder-blocks-dropdown/src/util/helpers.ts b/packages/wonder-blocks-dropdown/src/util/helpers.ts index 3a0742505..e6f0f7232 100644 --- a/packages/wonder-blocks-dropdown/src/util/helpers.ts +++ b/packages/wonder-blocks-dropdown/src/util/helpers.ts @@ -70,3 +70,17 @@ export function getLabel(props: OptionItemProps): string { return ""; } + +/** + * Returns the label for the SelectOpener in the SingleSelect and MultiSelect. + * If the label is a Node, and `labelAsText` is undefined, returns the label. + */ +export function getSelectOpenerLabel( + showOpenerLabelAsText: boolean, + props: OptionItemProps, +): string | JSX.Element { + if (showOpenerLabelAsText) { + return getLabel(props); + } + return props.label; +} From bb66c18b26935f5d110e93ed1564f45c7804439d Mon Sep 17 00:00:00 2001 From: Khan Actions Bot <56267880+khan-actions-bot@users.noreply.github.com> Date: Mon, 2 Dec 2024 15:55:02 -0500 Subject: [PATCH 07/26] Version Packages (#2377) * Version Packages * Trigger build from empty commit --------- Co-authored-by: github-actions[bot] Co-authored-by: Bea Esguerra --- .changeset/thirty-shirts-leave.md | 5 ----- packages/wonder-blocks-birthday-picker/CHANGELOG.md | 7 +++++++ packages/wonder-blocks-birthday-picker/package.json | 4 ++-- packages/wonder-blocks-dropdown/CHANGELOG.md | 6 ++++++ packages/wonder-blocks-dropdown/package.json | 2 +- 5 files changed, 16 insertions(+), 8 deletions(-) delete mode 100644 .changeset/thirty-shirts-leave.md diff --git a/.changeset/thirty-shirts-leave.md b/.changeset/thirty-shirts-leave.md deleted file mode 100644 index ac0c90b18..000000000 --- a/.changeset/thirty-shirts-leave.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/wonder-blocks-dropdown": minor ---- - -Allow use of JSX Element as label in SingleSelect and MultiSelect diff --git a/packages/wonder-blocks-birthday-picker/CHANGELOG.md b/packages/wonder-blocks-birthday-picker/CHANGELOG.md index c75070716..264f91fa2 100644 --- a/packages/wonder-blocks-birthday-picker/CHANGELOG.md +++ b/packages/wonder-blocks-birthday-picker/CHANGELOG.md @@ -1,5 +1,12 @@ # @khanacademy/wonder-blocks-birthday-picker +## 2.0.92 + +### Patch Changes + +- Updated dependencies [2b8424ca] + - @khanacademy/wonder-blocks-dropdown@5.8.0 + ## 2.0.91 ### Patch Changes diff --git a/packages/wonder-blocks-birthday-picker/package.json b/packages/wonder-blocks-birthday-picker/package.json index 875be89de..9a964bd39 100644 --- a/packages/wonder-blocks-birthday-picker/package.json +++ b/packages/wonder-blocks-birthday-picker/package.json @@ -1,6 +1,6 @@ { "name": "@khanacademy/wonder-blocks-birthday-picker", - "version": "2.0.91", + "version": "2.0.92", "design": "v1", "publishConfig": { "access": "public" @@ -15,7 +15,7 @@ "dependencies": { "@babel/runtime": "^7.18.6", "@khanacademy/wonder-blocks-core": "^7.0.1", - "@khanacademy/wonder-blocks-dropdown": "^5.7.0", + "@khanacademy/wonder-blocks-dropdown": "^5.8.0", "@khanacademy/wonder-blocks-icon": "^4.2.0", "@khanacademy/wonder-blocks-layout": "^2.2.2", "@khanacademy/wonder-blocks-tokens": "^2.1.0", diff --git a/packages/wonder-blocks-dropdown/CHANGELOG.md b/packages/wonder-blocks-dropdown/CHANGELOG.md index e1587c28a..f984bfc23 100644 --- a/packages/wonder-blocks-dropdown/CHANGELOG.md +++ b/packages/wonder-blocks-dropdown/CHANGELOG.md @@ -1,5 +1,11 @@ # @khanacademy/wonder-blocks-dropdown +## 5.8.0 + +### Minor Changes + +- 2b8424ca: Allow use of JSX Element as label in SingleSelect and MultiSelect + ## 5.7.0 ### Minor Changes diff --git a/packages/wonder-blocks-dropdown/package.json b/packages/wonder-blocks-dropdown/package.json index 468ce7ce4..171f8d3d2 100644 --- a/packages/wonder-blocks-dropdown/package.json +++ b/packages/wonder-blocks-dropdown/package.json @@ -1,6 +1,6 @@ { "name": "@khanacademy/wonder-blocks-dropdown", - "version": "5.7.0", + "version": "5.8.0", "design": "v1", "description": "Dropdown variants for Wonder Blocks.", "main": "dist/index.js", From c8b5b2e21e2c009309e9b16bb7865aa34045ee2b Mon Sep 17 00:00:00 2001 From: daniellewhyte <30729058+daniellewhyte@users.noreply.github.com> Date: Wed, 4 Dec 2024 15:12:44 -0600 Subject: [PATCH 08/26] [MulitSelect] Move `showOpenerLabelAsText` out of sharedProps (#2379) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: Follow up from #2354 the new `showOpenerLabel` was included in the `sharedProps` in the MultiSelect that are passed to the SelectOpener which caused many unit tests to fail when trying to update the dropdown package in webapp (https://github.com/Khan/webapp/pull/27791) Issue: XXX-XXXX ## Test plan: Install the snapshot in webapp, yarn test Author: daniellewhyte Reviewers: beaesguerra Required Reviewers: Approved By: beaesguerra Checks: ✅ Chromatic - Get results on regular PRs (ubuntu-latest, 20.x), ✅ Test / Test (ubuntu-latest, 20.x, 2/2), ✅ Test / Test (ubuntu-latest, 20.x, 1/2), ✅ Lint / Lint (ubuntu-latest, 20.x), ✅ Check build sizes (ubuntu-latest, 20.x), ✅ Chromatic - Build on regular PRs / chromatic (ubuntu-latest, 20.x), ✅ Publish npm snapshot (ubuntu-latest, 20.x), ⏭️ Chromatic - Skip on Release PR (changesets), ✅ Prime node_modules cache for primary configuration (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ gerald, ⏭️ dependabot Pull Request URL: https://github.com/Khan/wonder-blocks/pull/2379 --- .changeset/famous-buckets-vanish.md | 5 +++++ .../wonder-blocks-dropdown/src/components/multi-select.tsx | 1 + .../wonder-blocks-dropdown/src/components/single-select.tsx | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .changeset/famous-buckets-vanish.md diff --git a/.changeset/famous-buckets-vanish.md b/.changeset/famous-buckets-vanish.md new file mode 100644 index 000000000..b34d990f9 --- /dev/null +++ b/.changeset/famous-buckets-vanish.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/wonder-blocks-dropdown": patch +--- + +[MultiSelect and SingleSelect] Remove `showOpenerLabelAsText` from sharedProps that are passed to SelectOpener diff --git a/packages/wonder-blocks-dropdown/src/components/multi-select.tsx b/packages/wonder-blocks-dropdown/src/components/multi-select.tsx index e8922fe19..92b077f61 100644 --- a/packages/wonder-blocks-dropdown/src/components/multi-select.tsx +++ b/packages/wonder-blocks-dropdown/src/components/multi-select.tsx @@ -528,6 +528,7 @@ export default class MultiSelect extends React.Component { className, "aria-invalid": ariaInvalid, "aria-required": ariaRequired, + showOpenerLabelAsText, /* eslint-enable @typescript-eslint/no-unused-vars */ ...sharedProps } = this.props; diff --git a/packages/wonder-blocks-dropdown/src/components/single-select.tsx b/packages/wonder-blocks-dropdown/src/components/single-select.tsx index c3e31b17e..6075b2b97 100644 --- a/packages/wonder-blocks-dropdown/src/components/single-select.tsx +++ b/packages/wonder-blocks-dropdown/src/components/single-select.tsx @@ -398,6 +398,7 @@ export default class SingleSelect extends React.Component { placeholder, selectedValue, testId, + showOpenerLabelAsText, // the following props are being included here to avoid // passing them down to the opener as part of sharedProps /* eslint-disable @typescript-eslint/no-unused-vars */ @@ -414,7 +415,6 @@ export default class SingleSelect extends React.Component { className, "aria-invalid": ariaInvalid, "aria-required": ariaRequired, - showOpenerLabelAsText, ...sharedProps } = this.props; From d67761d7b27fc04a5fe59f4c56972b04453c2177 Mon Sep 17 00:00:00 2001 From: Khan Actions Bot <56267880+khan-actions-bot@users.noreply.github.com> Date: Wed, 4 Dec 2024 16:42:51 -0500 Subject: [PATCH 09/26] Version Packages (#2381) * Version Packages * Trigger build from empty commit --------- Co-authored-by: github-actions[bot] Co-authored-by: Bea Esguerra --- .changeset/famous-buckets-vanish.md | 5 ----- packages/wonder-blocks-birthday-picker/CHANGELOG.md | 7 +++++++ packages/wonder-blocks-birthday-picker/package.json | 4 ++-- packages/wonder-blocks-dropdown/CHANGELOG.md | 6 ++++++ packages/wonder-blocks-dropdown/package.json | 2 +- 5 files changed, 16 insertions(+), 8 deletions(-) delete mode 100644 .changeset/famous-buckets-vanish.md diff --git a/.changeset/famous-buckets-vanish.md b/.changeset/famous-buckets-vanish.md deleted file mode 100644 index b34d990f9..000000000 --- a/.changeset/famous-buckets-vanish.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@khanacademy/wonder-blocks-dropdown": patch ---- - -[MultiSelect and SingleSelect] Remove `showOpenerLabelAsText` from sharedProps that are passed to SelectOpener diff --git a/packages/wonder-blocks-birthday-picker/CHANGELOG.md b/packages/wonder-blocks-birthday-picker/CHANGELOG.md index 264f91fa2..8b7edd77e 100644 --- a/packages/wonder-blocks-birthday-picker/CHANGELOG.md +++ b/packages/wonder-blocks-birthday-picker/CHANGELOG.md @@ -1,5 +1,12 @@ # @khanacademy/wonder-blocks-birthday-picker +## 2.0.93 + +### Patch Changes + +- Updated dependencies [c8b5b2e2] + - @khanacademy/wonder-blocks-dropdown@5.8.1 + ## 2.0.92 ### Patch Changes diff --git a/packages/wonder-blocks-birthday-picker/package.json b/packages/wonder-blocks-birthday-picker/package.json index 9a964bd39..75549b1f9 100644 --- a/packages/wonder-blocks-birthday-picker/package.json +++ b/packages/wonder-blocks-birthday-picker/package.json @@ -1,6 +1,6 @@ { "name": "@khanacademy/wonder-blocks-birthday-picker", - "version": "2.0.92", + "version": "2.0.93", "design": "v1", "publishConfig": { "access": "public" @@ -15,7 +15,7 @@ "dependencies": { "@babel/runtime": "^7.18.6", "@khanacademy/wonder-blocks-core": "^7.0.1", - "@khanacademy/wonder-blocks-dropdown": "^5.8.0", + "@khanacademy/wonder-blocks-dropdown": "^5.8.1", "@khanacademy/wonder-blocks-icon": "^4.2.0", "@khanacademy/wonder-blocks-layout": "^2.2.2", "@khanacademy/wonder-blocks-tokens": "^2.1.0", diff --git a/packages/wonder-blocks-dropdown/CHANGELOG.md b/packages/wonder-blocks-dropdown/CHANGELOG.md index f984bfc23..fe5e94475 100644 --- a/packages/wonder-blocks-dropdown/CHANGELOG.md +++ b/packages/wonder-blocks-dropdown/CHANGELOG.md @@ -1,5 +1,11 @@ # @khanacademy/wonder-blocks-dropdown +## 5.8.1 + +### Patch Changes + +- c8b5b2e2: [MultiSelect and SingleSelect] Remove `showOpenerLabelAsText` from sharedProps that are passed to SelectOpener + ## 5.8.0 ### Minor Changes diff --git a/packages/wonder-blocks-dropdown/package.json b/packages/wonder-blocks-dropdown/package.json index 171f8d3d2..949142538 100644 --- a/packages/wonder-blocks-dropdown/package.json +++ b/packages/wonder-blocks-dropdown/package.json @@ -1,6 +1,6 @@ { "name": "@khanacademy/wonder-blocks-dropdown", - "version": "5.8.0", + "version": "5.8.1", "design": "v1", "description": "Dropdown variants for Wonder Blocks.", "main": "dist/index.js", From e6abdd170399b94d9eabc82519249565bacc3ddc Mon Sep 17 00:00:00 2001 From: Jeff Yates Date: Fri, 6 Dec 2024 15:52:08 -0600 Subject: [PATCH 10/26] [wb1670.1.react18] Upgrade to React 18 (#2380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: This builds off @jandrade's second attempt (that was PR #2372). The first commit is @jandrade's work from that PR, the subsequent commits are my additional changes to get us over the finish line. Issue: WB-1670 ### Dropdown fixes The `SingleSelect` virtualization was having an issue initially. The focus was not being shown on the first item on the list as it should. In investigation, I discovered a related issue where in virtualized lists, the first and last item could get skipped over when navigating with the keyboard. The following videos show the behavior for virtualized and non-virtualized variants to show that the issue has been fixed without breaking non-virtualized use. This video shows the before behavior, with the missing focus (a bug introduced with the React 18 update) and the skipped focus during navigation (a long term bug). https://github.com/user-attachments/assets/e7d3308e-33ed-4ed3-a9e7-5462404ae69c This video shows things after fixing them as part of this PR. https://github.com/user-attachments/assets/766dfb46-3358-474e-8718-e819e97c35e2 ## Test plan: `yarn test` `yarn typecheck` Author: somewhatabstract Reviewers: jandrade, beaesguerra, somewhatabstract Required Reviewers: Approved By: jandrade, beaesguerra Checks: ✅ Chromatic - Get results on regular PRs (ubuntu-latest, 20.x), ✅ Test / Test (ubuntu-latest, 20.x, 2/2), ✅ Test / Test (ubuntu-latest, 20.x, 1/2), ✅ Lint / Lint (ubuntu-latest, 20.x), ✅ Check build sizes (ubuntu-latest, 20.x), ✅ Chromatic - Build on regular PRs / chromatic (ubuntu-latest, 20.x), ✅ Publish npm snapshot (ubuntu-latest, 20.x), ⏭️ Chromatic - Skip on Release PR (changesets), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ gerald, ✅ Prime node_modules cache for primary configuration (ubuntu-latest, 20.x), ⏭️ dependabot Pull Request URL: https://github.com/Khan/wonder-blocks/pull/2380 --- .changeset/five-pens-accept.md | 38 + .eslintrc.js | 6 + .storybook/preview.tsx | 3 +- .../wonder-blocks-button/button.stories.tsx | 31 +- __docs__/wonder-blocks-link/link.stories.tsx | 160 ++- __docs__/wonder-blocks-pill/pill.stories.tsx | 289 +++--- config/jest/test-setup.js | 2 +- config/jest/test.config.js | 2 +- package.json | 32 +- packages/wonder-blocks-accordion/package.json | 2 +- .../__tests__/accordion-section.test.tsx | 23 +- .../components/__tests__/accordion.test.tsx | 6 +- packages/wonder-blocks-banner/package.json | 2 +- .../package.json | 2 +- .../__tests__/birthday-picker.test.tsx | 35 +- .../wonder-blocks-breadcrumbs/package.json | 2 +- packages/wonder-blocks-button/package.json | 6 +- packages/wonder-blocks-cell/package.json | 2 +- packages/wonder-blocks-clickable/package.json | 8 +- packages/wonder-blocks-core/package.json | 8 +- .../hooks/__tests__/use-force-update.test.tsx | 31 +- .../hooks/__tests__/use-is-mounted.test.tsx | 3 +- .../hooks/__tests__/use-latest-ref.test.ts | 2 +- .../__tests__/use-on-mount-effect.test.ts | 2 +- .../src/hooks/__tests__/use-online.test.tsx | 4 +- .../use-pre-hydration-effect.test.tsx | 6 +- .../hooks/__tests__/use-render-state.test.tsx | 42 +- .../wonder-blocks-core/src/util/add-style.tsx | 12 +- packages/wonder-blocks-data/package.json | 2 +- .../__tests__/use-cached-effect.test.tsx | 55 +- .../__tests__/use-gql-router-context.test.tsx | 10 +- .../src/hooks/__tests__/use-gql.test.tsx | 10 +- .../__tests__/use-hydratable-effect.test.ts | 99 +- .../use-request-interception.test.tsx | 36 +- .../hooks/__tests__/use-server-effect.test.ts | 23 +- .../hooks/__tests__/use-shared-cache.test.ts | 63 +- .../src/util/__tests__/request-api.test.ts | 1 - packages/wonder-blocks-dropdown/package.json | 10 +- .../components/__tests__/action-menu.test.tsx | 3 +- .../__tests__/multi-select.test.tsx | 4 +- .../__tests__/single-select.test.tsx | 11 +- .../src/components/dropdown-core.tsx | 96 +- packages/wonder-blocks-form/package.json | 2 +- .../__tests__/labeled-text-field.test.tsx | 2 +- .../__tests__/use-field-validation.test.ts | 18 +- packages/wonder-blocks-grid/package.json | 2 +- packages/wonder-blocks-i18n/package.json | 2 +- .../__tests__/i18n-inline-markup.test.tsx | 124 +-- .../wonder-blocks-icon-button/package.json | 6 +- packages/wonder-blocks-icon/package.json | 2 +- .../wonder-blocks-labeled-field/package.json | 2 +- packages/wonder-blocks-layout/package.json | 2 +- packages/wonder-blocks-link/package.json | 6 +- packages/wonder-blocks-modal/package.json | 4 +- packages/wonder-blocks-pill/package.json | 2 +- packages/wonder-blocks-popover/package.json | 4 +- .../__tests__/popover-event-listener.test.tsx | 17 +- .../src/components/__tests__/popover.test.tsx | 34 +- .../src/components/popover-anchor.ts | 4 - .../package.json | 2 +- .../wonder-blocks-search-field/package.json | 2 +- packages/wonder-blocks-switch/package.json | 2 +- .../wonder-blocks-testing-core/package.json | 8 +- .../__tests__/error-boundary.test.tsx | 3 +- .../src/harness/make-test-harness.tsx | 6 +- packages/wonder-blocks-testing/package.json | 8 +- .../harness/adapters/__tests__/data.test.tsx | 11 +- packages/wonder-blocks-theming/package.json | 4 +- .../hooks/__tests__/use-scoped-theme.test.tsx | 2 +- .../src/hooks/__tests__/use-styles.test.ts | 2 +- packages/wonder-blocks-timing/package.json | 5 +- .../src/hooks/__tests__/use-interval.test.ts | 19 +- .../src/hooks/__tests__/use-timeout.test.ts | 19 +- .../wonder-blocks-timing/tsconfig-build.json | 4 +- packages/wonder-blocks-toolbar/package.json | 2 +- packages/wonder-blocks-tooltip/package.json | 4 +- .../__tests__/tooltip-anchor.test.tsx | 24 +- .../__tests__/tooltip.integration.test.tsx | 12 +- .../wonder-blocks-typography/package.json | 2 +- tsconfig.json | 2 +- types/jest-extended.d.ts | 930 ++++++++++++++++++ types/matchers.d.ts | 1 - types/testing-library_jest-dom.d.ts | 751 ++++++++++++++ yarn.lock | 313 +++--- 84 files changed, 2648 insertions(+), 907 deletions(-) create mode 100644 .changeset/five-pens-accept.md create mode 100644 types/jest-extended.d.ts create mode 100644 types/testing-library_jest-dom.d.ts diff --git a/.changeset/five-pens-accept.md b/.changeset/five-pens-accept.md new file mode 100644 index 000000000..571cabbdc --- /dev/null +++ b/.changeset/five-pens-accept.md @@ -0,0 +1,38 @@ +--- +"@khanacademy/wonder-blocks-birthday-picker": major +"@khanacademy/wonder-blocks-testing-core": major +"@khanacademy/wonder-blocks-accordion": major +"@khanacademy/wonder-blocks-dropdown": major +"@khanacademy/wonder-blocks-popover": major +"@khanacademy/wonder-blocks-testing": major +"@khanacademy/wonder-blocks-theming": major +"@khanacademy/wonder-blocks-tooltip": major +"@khanacademy/wonder-blocks-timing": major +"@khanacademy/wonder-blocks-core": major +"@khanacademy/wonder-blocks-data": major +"@khanacademy/wonder-blocks-form": major +"@khanacademy/wonder-blocks-i18n": major +"@khanacademy/wb-dev-build-settings": major +"@khanacademy/wonder-blocks-banner": major +"@khanacademy/wonder-blocks-breadcrumbs": major +"@khanacademy/wonder-blocks-button": major +"@khanacademy/wonder-blocks-cell": major +"@khanacademy/wonder-blocks-clickable": major +"@khanacademy/wonder-blocks-grid": major +"@khanacademy/wonder-blocks-icon": major +"@khanacademy/wonder-blocks-icon-button": major +"@khanacademy/wonder-blocks-labeled-field": major +"@khanacademy/wonder-blocks-layout": major +"@khanacademy/wonder-blocks-link": major +"@khanacademy/wonder-blocks-modal": major +"@khanacademy/wonder-blocks-pill": major +"@khanacademy/wonder-blocks-progress-spinner": major +"@khanacademy/wonder-blocks-search-field": major +"@khanacademy/wonder-blocks-switch": major +"@khanacademy/wonder-blocks-tokens": major +"@khanacademy/wonder-blocks-toolbar": major +"@khanacademy/wonder-blocks-typography": major +"@khanacademy/wb-codemod": major +--- + +Upgrade to React 18 diff --git a/.eslintrc.js b/.eslintrc.js index 4ba9f5419..4dae5554b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -59,6 +59,12 @@ module.exports = { "no-undef": "off", }, }, + { + files: ["**/*.stories.tsx"], + rules: { + "testing-library/no-await-sync-events": "off", + }, + }, ], globals: { // `no-undef` doesn't support `globalThis`, for details see diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index fe9ec130b..2349861e7 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -95,7 +95,7 @@ const parameters = { }, }; -export const decorators = [ +const decorators = [ (Story, context) => { const theme = context.globals.theme; const enableRenderStateRootDecorator = @@ -120,6 +120,7 @@ export const decorators = [ const preview: Preview = { parameters, + decorators, globalTypes: { // Allow the user to select a theme from the toolbar. theme: { diff --git a/__docs__/wonder-blocks-button/button.stories.tsx b/__docs__/wonder-blocks-button/button.stories.tsx index 7980236d7..433dc8e6f 100644 --- a/__docs__/wonder-blocks-button/button.stories.tsx +++ b/__docs__/wonder-blocks-button/button.stories.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import {StyleSheet} from "aphrodite"; import type {Meta, StoryObj} from "@storybook/react"; -import {expect, fireEvent, userEvent, within} from "@storybook/test"; +import {expect, userEvent, within} from "@storybook/test"; import {MemoryRouter, Route, Switch} from "react-router-dom"; @@ -103,7 +103,6 @@ export const Tertiary: StoryComponentType = { // Get HTML elements const button = canvas.getByRole("button"); - const computedStyleButton = getComputedStyle(button); const innerLabel = canvas.getByTestId("test-button-inner-label"); const computedStyleLabel = getComputedStyle(innerLabel, ":after"); @@ -116,19 +115,23 @@ export const Tertiary: StoryComponentType = { await expect(computedStyleLabel.height).toBe("2px"); await expect(computedStyleLabel.color).toBe("rgb(24, 101, 242)"); + // TODO(WB-1808, somewhatabstract): This isn't working. I got it passing + // locally by calling `button.focus()` as well, but it was super flaky + // and never passed first time. // Focus style - await fireEvent.focus(button); - await expect(computedStyleButton.outlineColor).toBe( - "rgb(24, 101, 242)", - ); - await expect(computedStyleButton.outlineWidth).toBe("2px"); - - // Active (mouse down) style - // eslint-disable-next-line testing-library/prefer-user-event - await fireEvent.mouseDown(button); - await expect(innerLabel).toHaveStyle("color: rgb(27, 80, 179)"); - await expect(computedStyleLabel.height).toBe("2px"); - await expect(computedStyleLabel.color).toBe("rgb(27, 80, 179)"); + // const computedStyleButton = getComputedStyle(button); + // await fireEvent.focus(button); + // await expect(computedStyleButton.outlineColor).toBe( + // "rgb(24, 101, 242)", + // ); + // await expect(computedStyleButton.outlineWidth).toBe("2px"); + + // // Active (mouse down) style + // // eslint-disable-next-line testing-library/prefer-user-event + // await fireEvent.mouseDown(button); + // await expect(innerLabel).toHaveStyle("color: rgb(27, 80, 179)"); + // await expect(computedStyleLabel.color).toBe("rgb(27, 80, 179)"); + // await expect(computedStyleLabel.height).toBe("2px"); }, }; diff --git a/__docs__/wonder-blocks-link/link.stories.tsx b/__docs__/wonder-blocks-link/link.stories.tsx index 642b14dab..af9f341cc 100644 --- a/__docs__/wonder-blocks-link/link.stories.tsx +++ b/__docs__/wonder-blocks-link/link.stories.tsx @@ -3,7 +3,7 @@ // alternatives work. Click includes mouseUp, which removes the pressed style. /* eslint-disable testing-library/prefer-user-event */ import * as React from "react"; -import {expect, within, userEvent, fireEvent} from "@storybook/test"; +import {expect, within, userEvent /*fireEvent*/} from "@storybook/test"; import {StyleSheet} from "aphrodite"; import {MemoryRouter, Route, Switch} from "react-router-dom"; import type {Meta, StoryObj} from "@storybook/react"; @@ -38,8 +38,8 @@ export default { argTypes: LinkArgTypes, } as Meta; -const activeBlue = "#1b50b3"; -const fadedBlue = "#b5cefb"; +// const activeBlue = "#1b50b3"; +// const fadedBlue = "#b5cefb"; type StoryComponentType = StoryObj; @@ -81,18 +81,17 @@ Primary.play = async ({canvasElement}) => { `text-decoration: underline ${color.blue} solid`, ); - // Focus style with keyboard navigation - await userEvent.tab(); - const computedStyle = getComputedStyle(link, ":focus-visible"); - // rgb(24, 101, 242) is the same as Color.blue. `toBe` doesn't seem to - // compare different color formats, so hex was converted to RGB. - await expect(computedStyle.outline).toBe("rgb(24, 101, 242) solid 1px"); - - // Mousedown style - await fireEvent.mouseDown(link); - await expect(link).toHaveStyle( - `text-decoration: underline solid ${activeBlue}`, - ); + // TODO(WB-1809, somewhatabstract): This isn't working. + // // Focus style with keyboard navigation + // await userEvent.tab(); + // // rgb(24, 101, 242) is the same as Color.blue + // await expect(link).toHaveStyle("outline: rgb(24, 101, 242) solid 1px"); + + // // Mousedown style + // await fireEvent.mouseDown(link); + // await expect(link).toHaveStyle( + // `text-decoration: underline solid ${activeBlue}`, + // ); }; export const Secondary: StoryComponentType = () => ( @@ -128,18 +127,17 @@ Secondary.play = async ({canvasElement}) => { `text-decoration: underline ${color.offBlack64} solid`, ); - // Focus style with keyboard navigation - await userEvent.tab(); - const computedStyle = getComputedStyle(link, ":focus-visible"); - // rgb(24, 101, 242) is the same as Color.blue. `toBe` doesn't seem to - // compare different color formats, so hex was converted to RGB. - await expect(computedStyle.outline).toBe("rgb(24, 101, 242) solid 1px"); - - // Mousedown style - await fireEvent.mouseDown(link); - await expect(link).toHaveStyle( - `text-decoration: underline solid ${color.offBlack}`, - ); + // TODO(WB-1809, somewhatabstract): This isn't working. + // // Focus style with keyboard navigation + // await userEvent.tab(); + // // rgb(24, 101, 242) is the same as Color.blue. + // await expect(link).toHaveStyle("outline: rgb(24, 101, 242) solid 1px"); + + // // Mousedown style + // await fireEvent.mouseDown(link); + // await expect(link).toHaveStyle( + // `text-decoration: underline solid ${color.offBlack}`, + // ); }; export const Visitable: StoryComponentType = () => ( @@ -196,18 +194,17 @@ LightPrimary.play = async ({canvasElement}) => { `text-decoration: underline ${color.white} solid`, ); + // TODO(WB-1809, somewhatabstract): This isn't working. // Focus style with keyboard navigation - await userEvent.tab(); - const computedStyle = getComputedStyle(link, ":focus-visible"); - // rgb(255, 255, 255) is the same as Color.white. `toBe` doesn't seem to - // compare different color formats, so hex was converted to RGB. - await expect(computedStyle.outline).toBe("rgb(255, 255, 255) solid 1px"); - - // Mousedown style - await fireEvent.mouseDown(link); - await expect(link).toHaveStyle( - `text-decoration: underline solid ${fadedBlue}`, - ); + // await userEvent.tab(); + // // rgb(255, 255, 255) is the same as Color.white. + // await expect(link).toHaveStyle("outline: rgb(255, 255, 255) solid 1px"); + + // // Mousedown style + // await fireEvent.mouseDown(link); + // await expect(link).toHaveStyle( + // `text-decoration: underline solid ${fadedBlue}`, + // ); }; export const LightVisitable: StoryComponentType = () => ( @@ -490,23 +487,19 @@ Inline.play = async ({canvasElement}) => { `text-decoration: underline ${color.blue} solid`, ); - // Focus style with keyboard navigation - await userEvent.tab(); - const primaryComputedStyle = getComputedStyle( - primaryLink, - ":focus-visible", - ); - // rgb(24, 101, 242) is the same as Color.blue. `toBe` doesn't seem to - // compare different color formats, so hex was converted to RGB. - await expect(primaryComputedStyle.outline).toBe( - "rgb(24, 101, 242) solid 1px", - ); + // TODO(WB-1809, somewhatabstract): This isn't working. + // // Focus style with keyboard navigation + // await userEvent.tab(); + // // rgb(24, 101, 242) is the same as Color.blue. + // await expect(primaryLink).toHaveStyle( + // "outline: rgb(24, 101, 242) solid 1px", + // ); - // Mousedown style - await fireEvent.mouseDown(primaryLink); - await expect(primaryLink).toHaveStyle( - `text-decoration: underline solid ${activeBlue}`, - ); + // // Mousedown style + // await fireEvent.mouseDown(primaryLink); + // await expect(primaryLink).toHaveStyle( + // `text-decoration: underline solid ${activeBlue}`, + // ); /* *** Secondary link styles*** */ @@ -522,25 +515,20 @@ Inline.play = async ({canvasElement}) => { await expect(secondaryLink).toHaveStyle( `text-decoration: underline ${color.offBlack} solid`, ); + // TODO(WB-1809, somewhatabstract): This isn't working. + // // Focus style with keyboard navigation + // await userEvent.tab(); + // await userEvent.tab(); + // // rgb(24, 101, 242) is the same as Color.blue. + // await expect(secondaryLink).toHaveStyle( + // "outline: rgb(24, 101, 242) solid 1px", + // ); - // Focus style with keyboard navigation - await userEvent.tab(); - await userEvent.tab(); - const secondaryComputedStyle = getComputedStyle( - secondaryLink, - ":focus-visible", - ); - // rgb(24, 101, 242) is the same as Color.blue. `toBe` doesn't seem to - // compare different color formats, so hex was converted to RGB. - await expect(secondaryComputedStyle.outline).toBe( - "rgb(24, 101, 242) solid 1px", - ); - - // Mousedown style - await fireEvent.mouseDown(secondaryLink); - await expect(secondaryLink).toHaveStyle( - `text-decoration: underline solid ${activeBlue}`, - ); + // // Mousedown style + // await fireEvent.mouseDown(secondaryLink); + // await expect(secondaryLink).toHaveStyle( + // `text-decoration: underline solid ${activeBlue}`, + // ); }; export const InlineLight: StoryComponentType = () => ( @@ -591,7 +579,8 @@ InlineLight.parameters = { }, }; -InlineLight.play = async ({canvasElement}) => { +// TODO(WB-1809, somewhatabstract): This isn't working. +/* InlineLight.play = async ({canvasElement}) => { const canvas = within(canvasElement); const primaryLink = canvas.getByRole("link", {name: "Primary link"}); @@ -609,19 +598,20 @@ InlineLight.play = async ({canvasElement}) => { `text-decoration: underline ${color.white} solid`, ); - // Focus style with keyboard navigation - await userEvent.tab(); - const computedStyle = getComputedStyle(primaryLink, ":focus-visible"); - // rgb(255, 255, 255) is the same as Color.white. `toBe` doesn't seem to - // compare different color formats, so hex was converted to RGB. - await expect(computedStyle.outline).toBe("rgb(255, 255, 255) solid 1px"); - - // Mousedown style - await fireEvent.mouseDown(primaryLink); - await expect(primaryLink).toHaveStyle( - `text-decoration: underline solid ${fadedBlue}`, - ); + // // Focus style with keyboard navigation + // await userEvent.tab(); + // // rgb(255, 255, 255) is the same as Color.white. + // await expect(primaryLink).toHaveStyle( + // "outline: rgb(255, 255, 255) solid 1px", + // ); + + // // Mousedown style + // await fireEvent.mouseDown(primaryLink); + // await expect(primaryLink).toHaveStyle( + // `text-decoration: underline solid ${fadedBlue}`, + // ); }; +*/ export const Variants: StoryComponentType = () => ( diff --git a/__docs__/wonder-blocks-pill/pill.stories.tsx b/__docs__/wonder-blocks-pill/pill.stories.tsx index 5534f51ec..fa4099721 100644 --- a/__docs__/wonder-blocks-pill/pill.stories.tsx +++ b/__docs__/wonder-blocks-pill/pill.stories.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import type {Meta, StoryObj} from "@storybook/react"; -import {expect, within, userEvent} from "@storybook/test"; +// import {expect, within} from "@storybook/test"; import {View} from "@khanacademy/wonder-blocks-core"; import Link from "@khanacademy/wonder-blocks-link"; import Pill from "@khanacademy/wonder-blocks-pill"; @@ -205,148 +205,151 @@ export const Variants: StoryComponentType = { }; // Test visual styles -Variants.play = async ({canvasElement}) => { - const canvas = within(canvasElement); - - // Define non-clickable pills - const neutralSmall = canvas.getByTestId("neutral-small-test-id"); - const accentSmall = canvas.getByTestId("accent-small-test-id"); - const infoSmall = canvas.getByTestId("info-small-test-id"); - const successSmall = canvas.getByTestId("success-small-test-id"); - const warningSmall = canvas.getByTestId("warning-small-test-id"); - const criticalSmall = canvas.getByTestId("critical-small-test-id"); - const neutralMedium = canvas.getByTestId("neutral-medium-test-id"); - const neutralLarge = canvas.getByTestId("neutral-large-test-id"); - const accentLarge = canvas.getByTestId("accent-large-test-id"); - const infoLarge = canvas.getByTestId("info-large-test-id"); - const successLarge = canvas.getByTestId("success-large-test-id"); - const warningLarge = canvas.getByTestId("warning-large-test-id"); - const criticalLarge = canvas.getByTestId("critical-large-test-id"); - - // Define clickable pills - const neutralMediumClickable = canvas.getByTestId( - "neutral-medium-clickable-test-id", - ); - const accentMediumClickable = canvas.getByTestId( - "accent-medium-clickable-test-id", - ); - const infoMediumClickable = canvas.getByTestId( - "info-medium-clickable-test-id", - ); - const successMediumClickable = canvas.getByTestId( - "success-medium-clickable-test-id", - ); - const warningMediumClickable = canvas.getByTestId( - "warning-medium-clickable-test-id", - ); - const criticalMediumClickable = canvas.getByTestId( - "critical-medium-clickable-test-id", - ); - - // Test non-clickable pill styles - await expect(neutralSmall).toHaveStyle({ - backgroundColor: tokens.color.offBlack8, - color: tokens.color.offBlack, - fontSize: 12, - }); - - await expect(accentSmall).toHaveStyle({ - backgroundColor: tokens.color.blue, - color: tokens.color.white, - fontSize: 12, - }); - - await expect(infoSmall).toHaveStyle({ - backgroundColor: tokens.color.fadedBlue16, - color: tokens.color.offBlack, - fontSize: 12, - }); - - await expect(successSmall).toHaveStyle({ - backgroundColor: tokens.color.fadedGreen16, - color: tokens.color.offBlack, - fontSize: 12, - }); - - await expect(warningSmall).toHaveStyle({ - backgroundColor: tokens.color.fadedGold16, - color: tokens.color.offBlack, - fontSize: 12, - }); - - await expect(criticalSmall).toHaveStyle({ - backgroundColor: tokens.color.fadedRed16, - color: tokens.color.offBlack, - fontSize: 12, - }); - - await expect(neutralMedium).toHaveStyle({ - backgroundColor: tokens.color.offBlack8, - color: tokens.color.offBlack, - fontSize: 14, - }); - - await expect(neutralLarge).toHaveStyle({ - backgroundColor: tokens.color.offBlack8, - color: tokens.color.offBlack, - fontSize: 16, - }); - - await expect(accentLarge).toHaveStyle({ - backgroundColor: tokens.color.blue, - color: tokens.color.white, - fontSize: 16, - }); - - await expect(infoLarge).toHaveStyle({ - backgroundColor: tokens.color.fadedBlue16, - color: tokens.color.offBlack, - fontSize: 16, - }); - - await expect(successLarge).toHaveStyle({ - backgroundColor: tokens.color.fadedGreen16, - color: tokens.color.offBlack, - fontSize: 16, - }); - - await expect(warningLarge).toHaveStyle({ - backgroundColor: tokens.color.fadedGold16, - color: tokens.color.offBlack, - fontSize: 16, - }); - - await expect(criticalLarge).toHaveStyle({ - backgroundColor: tokens.color.fadedRed16, - color: tokens.color.offBlack, - fontSize: 16, - }); - - // Test clickable pill styles - await neutralMediumClickable.focus(); - let computedStyle = getComputedStyle(neutralMediumClickable, ":hover"); - await expect(computedStyle.outline).toBe("rgb(24, 101, 242) solid 2px"); - - await userEvent.tab(); - computedStyle = getComputedStyle(accentMediumClickable, ":hover"); - await expect(computedStyle.outline).toBe("rgb(24, 101, 242) solid 2px"); - - await userEvent.tab(); - computedStyle = getComputedStyle(infoMediumClickable, ":hover"); - await expect(computedStyle.outline).toBe("rgb(24, 101, 242) solid 2px"); - - await userEvent.tab(); - computedStyle = getComputedStyle(successMediumClickable, ":hover"); - await expect(computedStyle.outline).toBe("rgb(24, 101, 242) solid 2px"); - - await userEvent.tab(); - computedStyle = getComputedStyle(warningMediumClickable, ":hover"); - await expect(computedStyle.outline).toBe("rgb(24, 101, 242) solid 2px"); - - await userEvent.tab(); - computedStyle = getComputedStyle(criticalMediumClickable, ":hover"); - await expect(computedStyle.outline).toBe("rgb(217, 41, 22) solid 2px"); -}; +// TODO(WB-1810, somewhatabstract): These aren't working. I got some passing +// locally by calling `.focus()` directly on the elements as well as via +// fireEvent, but it was super duper flaky and never passed first time. +// Variants.play = async ({canvasElement}) => { +// const canvas = within(canvasElement); + +// // Define non-clickable pills +// const neutralSmall = canvas.getByTestId("neutral-small-test-id"); +// const accentSmall = canvas.getByTestId("accent-small-test-id"); +// const infoSmall = canvas.getByTestId("info-small-test-id"); +// const successSmall = canvas.getByTestId("success-small-test-id"); +// const warningSmall = canvas.getByTestId("warning-small-test-id"); +// const criticalSmall = canvas.getByTestId("critical-small-test-id"); +// const neutralMedium = canvas.getByTestId("neutral-medium-test-id"); +// const neutralLarge = canvas.getByTestId("neutral-large-test-id"); +// const accentLarge = canvas.getByTestId("accent-large-test-id"); +// const infoLarge = canvas.getByTestId("info-large-test-id"); +// const successLarge = canvas.getByTestId("success-large-test-id"); +// const warningLarge = canvas.getByTestId("warning-large-test-id"); +// const criticalLarge = canvas.getByTestId("critical-large-test-id"); + +// // Test non-clickable pill styles +// await expect(neutralSmall).toHaveStyle({ +// backgroundColor: tokens.color.offBlack8, +// color: tokens.color.offBlack, +// fontSize: 12, +// }); + +// await expect(accentSmall).toHaveStyle({ +// backgroundColor: tokens.color.blue, +// color: tokens.color.white, +// fontSize: 12, +// }); + +// await expect(infoSmall).toHaveStyle({ +// backgroundColor: tokens.color.fadedBlue16, +// color: tokens.color.offBlack, +// fontSize: 12, +// }); + +// await expect(successSmall).toHaveStyle({ +// backgroundColor: tokens.color.fadedGreen16, +// color: tokens.color.offBlack, +// fontSize: 12, +// }); + +// await expect(warningSmall).toHaveStyle({ +// backgroundColor: tokens.color.fadedGold16, +// color: tokens.color.offBlack, +// fontSize: 12, +// }); + +// await expect(criticalSmall).toHaveStyle({ +// backgroundColor: tokens.color.fadedRed16, +// color: tokens.color.offBlack, +// fontSize: 12, +// }); + +// await expect(neutralMedium).toHaveStyle({ +// backgroundColor: tokens.color.offBlack8, +// color: tokens.color.offBlack, +// fontSize: 14, +// }); + +// await expect(neutralLarge).toHaveStyle({ +// backgroundColor: tokens.color.offBlack8, +// color: tokens.color.offBlack, +// fontSize: 16, +// }); + +// await expect(accentLarge).toHaveStyle({ +// backgroundColor: tokens.color.blue, +// color: tokens.color.white, +// fontSize: 16, +// }); + +// await expect(infoLarge).toHaveStyle({ +// backgroundColor: tokens.color.fadedBlue16, +// color: tokens.color.offBlack, +// fontSize: 16, +// }); + +// await expect(successLarge).toHaveStyle({ +// backgroundColor: tokens.color.fadedGreen16, +// color: tokens.color.offBlack, +// fontSize: 16, +// }); + +// await expect(warningLarge).toHaveStyle({ +// backgroundColor: tokens.color.fadedGold16, +// color: tokens.color.offBlack, +// fontSize: 16, +// }); + +// await expect(criticalLarge).toHaveStyle({ +// backgroundColor: tokens.color.fadedRed16, +// color: tokens.color.offBlack, +// fontSize: 16, +// }); + +// // Define clickable pills +// // const neutralMediumClickable = canvas.getByTestId( +// // "neutral-medium-clickable-test-id", +// // ); +// // const accentMediumClickable = canvas.getByTestId( +// // "accent-medium-clickable-test-id", +// // ); +// // const infoMediumClickable = canvas.getByTestId( +// // "info-medium-clickable-test-id", +// // ); +// // const successMediumClickable = canvas.getByTestId( +// // "success-medium-clickable-test-id", +// // ); +// // const warningMediumClickable = canvas.getByTestId( +// // "warning-medium-clickable-test-id", +// // ); +// // const criticalMediumClickable = canvas.getByTestId( +// // "critical-medium-clickable-test-id", +// // ); + +// // Test clickable pill styles +// // await fireEvent.focus(neutralMediumClickable); +// // let computedStyle = getComputedStyle(neutralMediumClickable, ":hover"); +// // await expect(computedStyle.outline).toBe("rgb(24, 101, 242) solid 2px"); + +// // await userEvent.tab(); +// // computedStyle = getComputedStyle(accentMediumClickable, ":hover"); +// // await expect(computedStyle.outline).toBe("rgb(24, 101, 242) solid 2px"); + +// // await userEvent.tab(); +// // computedStyle = getComputedStyle(infoMediumClickable, ":hover"); +// // await expect(computedStyle.outline).toBe("rgb(24, 101, 242) solid 2px"); + +// // await userEvent.tab(); +// // computedStyle = getComputedStyle(successMediumClickable, ":hover"); +// // await expect(computedStyle.outline).toBe("rgb(24, 101, 242) solid 2px"); + +// // await userEvent.tab(); +// // computedStyle = getComputedStyle(warningMediumClickable, ":hover"); +// // await expect(computedStyle.outline).toBe("rgb(24, 101, 242) solid 2px"); + +// // await userEvent.tab(); +// // computedStyle = getComputedStyle(criticalMediumClickable, ":hover"); +// // await expect(computedStyle.outline).toBe("rgb(217, 41, 22) solid 2px"); +// }; export const WithTypography: StoryComponentType = () => ( diff --git a/config/jest/test-setup.js b/config/jest/test-setup.js index a41691966..f319e6bee 100644 --- a/config/jest/test-setup.js +++ b/config/jest/test-setup.js @@ -15,7 +15,7 @@ const attachShims = (targetWindow) => { if (!targetWindow.TextDecoder) { targetWindow.TextDecoder = TextDecoder; } -} +}; const resetWindow = () => { attachShims(globalThis); diff --git a/config/jest/test.config.js b/config/jest/test.config.js index 7e03c95cd..f9f403752 100644 --- a/config/jest/test.config.js +++ b/config/jest/test.config.js @@ -26,7 +26,7 @@ module.exports = { "/**/*.test.tsx", ], setupFilesAfterEnv: [ - "@testing-library/jest-dom/extend-expect", + "@testing-library/jest-dom", "/config/jest/test-setup.js", "jest-extended/all", "/config/jest/matchers/to-have-no-a11y-violations.ts", diff --git a/package.json b/package.json index 1108a039f..eed2daec7 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.21.5", "@changesets/cli": "^2.26.1", + "@jest/globals": "^29.7.0", "@khanacademy/eslint-config": "^2.0.0", "@khanacademy/eslint-plugin": "^2.0.0", "@khanacademy/wonder-stuff-testing": "^3.0.1", @@ -64,17 +65,16 @@ "@storybook/test": "^8.2.1", "@swc-node/register": "^1.6.5", "@swc/core": "^1.3.36", - "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^12.1.2", - "@testing-library/react-hooks": "^7.0.2", - "@testing-library/user-event": "^14.5.1", + "@testing-library/jest-dom": "^6.5.0", + "@testing-library/react": "^16.0.1", + "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.12", "@types/jest-axe": "^3.5.9", "@types/jscodeshift": "^0.11.11", "@types/node": "^18.14.1", "@types/node-fetch": "^2.6.11", - "@types/react": "16", - "@types/react-dom": "16", + "@types/react": "18", + "@types/react-dom": "18", "@types/react-router": "5", "@types/react-router-dom": "5", "@types/react-window": "^1.8.5", @@ -98,7 +98,7 @@ "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-storybook": "^0.6.15", - "eslint-plugin-testing-library": "^5.0.0", + "eslint-plugin-testing-library": "^6.3.0", "eslint-watch": "^8.0.0", "fast-glob": "^3.2.12", "ignore": "^5.3.2", @@ -112,7 +112,7 @@ "prettier": "^2.8.1", "react-refresh": "^0.14.0", "rollup": "^2.79.1", - "rollup-plugin-babel": "^4.0.0-beta.2", + "rollup-plugin-babel": "^4.4.0", "rollup-plugin-node-externals": "^7.1.2", "storybook": "^8.2.1", "storybook-addon-pseudo-states": "^3.1.1", @@ -129,12 +129,12 @@ "aphrodite": "^1.2.5", "moment": "2.29.4", "node-fetch": "^2.6.7", - "react": "16.14.0", - "react-dom": "16.14.0", + "react": "18.2.0", + "react-dom": "18.2.0", "react-popper": "^2.3.0", - "react-router": "5.2.1", - "react-router-dom": "5.3.0", - "react-window": "^1.8.7" + "react-router": "5.3.4", + "react-router-dom": "5.3.4", + "react-window": "^1.8.10" }, "workspaces": [ "packages/*", @@ -143,11 +143,9 @@ ], "resolutions": { "@figspec/components": "^2.0.4", - "@testing-library/dom": "^8.0.0", - "@testing-library/jest-dom": "^5.16.5", "@testing-library/user-event": "^14.5.1", - "@types/react": "16", - "@types/react-dom": "16", + "@types/react": "18", + "@types/react-dom": "18", "strip-ansi": "6.0.1", "strip-ansi-explanation": "There's an issue with strip-ansi v7 which causes conflicts with the Khan/changeset-per-package action" } diff --git a/packages/wonder-blocks-accordion/package.json b/packages/wonder-blocks-accordion/package.json index 3adb82f86..eb73a4951 100644 --- a/packages/wonder-blocks-accordion/package.json +++ b/packages/wonder-blocks-accordion/package.json @@ -25,7 +25,7 @@ "peerDependencies": { "@phosphor-icons/core": "^2.0.2", "aphrodite": "^1.2.5", - "react": "16.14.0" + "react": "18.2.0" }, "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1" diff --git a/packages/wonder-blocks-accordion/src/components/__tests__/accordion-section.test.tsx b/packages/wonder-blocks-accordion/src/components/__tests__/accordion-section.test.tsx index 821a7e25d..a7e837366 100644 --- a/packages/wonder-blocks-accordion/src/components/__tests__/accordion-section.test.tsx +++ b/packages/wonder-blocks-accordion/src/components/__tests__/accordion-section.test.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import {render, screen} from "@testing-library/react"; +import {userEvent} from "@testing-library/user-event"; import {RenderStateRoot} from "@khanacademy/wonder-blocks-core"; @@ -52,7 +53,7 @@ describe("AccordionSection", () => { expect(screen.queryByText("Section content")).toBeVisible(); }); - test("calls onToggle when clicked (controlled)", () => { + test("calls onToggle when clicked (controlled)", async () => { // Arrange const onToggleSpy = jest.fn(); @@ -70,13 +71,13 @@ describe("AccordionSection", () => { const button = screen.getByRole("button", {name: "Title"}); // Act - button.click(); + await userEvent.click(button); // Assert expect(onToggleSpy).toHaveBeenCalledTimes(1); }); - test("calls onToggle when clicked (uncontrolled: no expanded, includes onToggle)", () => { + test("calls onToggle when clicked (uncontrolled: no expanded, includes onToggle)", async () => { // Arrange const onToggleSpy = jest.fn(); @@ -90,13 +91,13 @@ describe("AccordionSection", () => { const button = screen.getByRole("button", {name: "Title"}); // Act - button.click(); + await userEvent.click(button); // Assert expect(onToggleSpy).toHaveBeenCalledTimes(1); }); - test("shows/hides panel when clicked (uncontrolled: includes expanded, no onToggle)", () => { + test("shows/hides panel when clicked (uncontrolled: includes expanded, no onToggle)", async () => { // Arrange render( @@ -110,17 +111,17 @@ describe("AccordionSection", () => { expect(screen.getByText("Section content")).toBeVisible(); const button = screen.getByRole("button", {name: "Title"}); - button.click(); + await userEvent.click(button); // Assert // Make sure the section has closed after clicking expect(screen.queryByText("Section content")).not.toBeVisible(); // Repeat clicking to confirm behavior - button.click(); + await userEvent.click(button); expect(screen.getByText("Section content")).toBeVisible(); }); - test("shows/hides panel when clicked (uncontrolled: no expanded, no onToggle)", () => { + test("shows/hides panel when clicked (uncontrolled: no expanded, no onToggle)", async () => { // Arrange render( Section content, @@ -132,13 +133,13 @@ describe("AccordionSection", () => { expect(screen.queryByText("Section content")).not.toBeVisible(); const button = screen.getByRole("button", {name: "Title"}); - button.click(); + await userEvent.click(button); // Assert // Make sure the section has opened after clicking expect(screen.getByText("Section content")).toBeVisible(); // Repeat clicking to confirm behavior - button.click(); + await userEvent.click(button); expect(screen.queryByText("Section content")).not.toBeVisible(); }); @@ -288,7 +289,7 @@ describe("AccordionSection", () => { expect(button).toHaveAttribute("aria-disabled", "true"); }); - test("does not allow clicking when collapsible prop is false", () => { + test("does not allow clicking when collapsible prop is false", async () => { // Arrange render( diff --git a/packages/wonder-blocks-accordion/src/components/__tests__/accordion.test.tsx b/packages/wonder-blocks-accordion/src/components/__tests__/accordion.test.tsx index c9deb7cc8..98777e52f 100644 --- a/packages/wonder-blocks-accordion/src/components/__tests__/accordion.test.tsx +++ b/packages/wonder-blocks-accordion/src/components/__tests__/accordion.test.tsx @@ -47,8 +47,8 @@ describe("Accordion", () => { const button2 = await screen.findByRole("button", {name: "Section 2"}); // Act - button1.click(); - button2.click(); + await userEvent.click(button1); + await userEvent.click(button2); // Assert expect(await screen.findByText("Section 1 content")).toBeVisible(); @@ -128,7 +128,7 @@ describe("Accordion", () => { // Act const button = await screen.findByRole("button", {name: "Section 3"}); - button.click(); + await userEvent.click(button); // Assert expect(screen.queryByText("Section 1 content")).not.toBeVisible(); diff --git a/packages/wonder-blocks-banner/package.json b/packages/wonder-blocks-banner/package.json index 28d998f18..068470ab6 100644 --- a/packages/wonder-blocks-banner/package.json +++ b/packages/wonder-blocks-banner/package.json @@ -27,7 +27,7 @@ "peerDependencies": { "@phosphor-icons/core": "^2.0.2", "aphrodite": "^1.2.5", - "react": "16.14.0" + "react": "18.2.0" }, "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1" diff --git a/packages/wonder-blocks-birthday-picker/package.json b/packages/wonder-blocks-birthday-picker/package.json index 75549b1f9..0cf82785a 100644 --- a/packages/wonder-blocks-birthday-picker/package.json +++ b/packages/wonder-blocks-birthday-picker/package.json @@ -25,7 +25,7 @@ "@phosphor-icons/core": "^2.0.2", "aphrodite": "^1.2.5", "moment": "^2.24.0", - "react": "16.14.0" + "react": "18.2.0" }, "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1" diff --git a/packages/wonder-blocks-birthday-picker/src/components/__tests__/birthday-picker.test.tsx b/packages/wonder-blocks-birthday-picker/src/components/__tests__/birthday-picker.test.tsx index 41e74ff54..cbdfaa822 100644 --- a/packages/wonder-blocks-birthday-picker/src/components/__tests__/birthday-picker.test.tsx +++ b/packages/wonder-blocks-birthday-picker/src/components/__tests__/birthday-picker.test.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import moment from "moment"; -import {render, screen} from "@testing-library/react"; +import {render, screen, waitFor} from "@testing-library/react"; import * as DateMock from "jest-date-mock"; import {userEvent, PointerEventsCheckLevel} from "@testing-library/user-event"; @@ -369,20 +369,17 @@ describe("BirthdayPicker", () => { if (!maybeInstance) { throw new Error("BirthdayPicker instance is undefined"); } - const instance = maybeInstance; + const instance: any = maybeInstance; // This test was written by calling methods on the instance because // react-window (used by SingleSelect) doesn't show all of the items // in the dropdown. - // @ts-expect-error [FEI-5019] - TS2339 - Property 'handleMonthChange' does not exist on type 'never'. instance.handleMonthChange("1"); - // @ts-expect-error [FEI-5019] - TS2339 - Property 'handleDayChange' does not exist on type 'never'. instance.handleDayChange("31"); - // @ts-expect-error [FEI-5019] - TS2339 - Property 'handleYearChange' does not exist on type 'never'. instance.handleYearChange("2021"); // Assert - expect(onChange).toHaveBeenCalledWith(null); + await waitFor(() => expect(onChange).toHaveBeenCalledWith(null)); }); it("onChange triggers only one null when multiple invalid values are selected after a default value is set", async () => { @@ -493,7 +490,7 @@ describe("BirthdayPicker", () => { render( {}} />); // Assert - expect(await screen.findByText(label)).toBeInTheDocument(); + await screen.findByText(label); }, ); @@ -515,9 +512,7 @@ describe("BirthdayPicker", () => { ); // Assert - expect( - await screen.findByText(translatedLabel), - ).toBeInTheDocument(); + await screen.findByText(translatedLabel); }, ); @@ -557,19 +552,23 @@ describe("BirthdayPicker", () => { ); // Assert - expect( - await screen.findByText(translatedLabels.errorMessage), - ).toBeInTheDocument(); + await screen.findByText(translatedLabels.errorMessage); }); }); describe("keyboard", () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - it("should find and select an item using the keyboard", async () => { + /* + The keyboard events (I tried .keyboard and .type) are not working as + needed. From what I can tell, they are going to the wrong element or + otherwise not getting handled as they would in a non-test world. + We had this issue with elsewhere too and haven't resolved it (since + updating to UserEvents v14, it seems). Skipping this test for now + until we can work out how to replicate things again. This could be + changed to a storybook test perhaps. + */ + it.skip("should find and select an item using the keyboard", async () => { // Arrange + jest.useFakeTimers(); const ue = userEvent.setup({ advanceTimers: jest.advanceTimersByTime, pointerEventsCheck: PointerEventsCheckLevel.Never, diff --git a/packages/wonder-blocks-breadcrumbs/package.json b/packages/wonder-blocks-breadcrumbs/package.json index f6378060b..97e8f031e 100644 --- a/packages/wonder-blocks-breadcrumbs/package.json +++ b/packages/wonder-blocks-breadcrumbs/package.json @@ -21,7 +21,7 @@ }, "peerDependencies": { "aphrodite": "^1.2.5", - "react": "16.14.0" + "react": "18.2.0" }, "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1" diff --git a/packages/wonder-blocks-button/package.json b/packages/wonder-blocks-button/package.json index 1f7a1601d..9d7e9f457 100644 --- a/packages/wonder-blocks-button/package.json +++ b/packages/wonder-blocks-button/package.json @@ -26,9 +26,9 @@ }, "peerDependencies": { "aphrodite": "^1.2.5", - "react": "16.14.0", - "react-router": "5.2.1", - "react-router-dom": "5.3.0" + "react": "18.2.0", + "react-router": "5.3.4", + "react-router-dom": "5.3.4" }, "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1" diff --git a/packages/wonder-blocks-cell/package.json b/packages/wonder-blocks-cell/package.json index 8a93df8d9..5ba459ffb 100644 --- a/packages/wonder-blocks-cell/package.json +++ b/packages/wonder-blocks-cell/package.json @@ -22,7 +22,7 @@ }, "peerDependencies": { "aphrodite": "^1.2.5", - "react": "16.14.0" + "react": "18.2.0" }, "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1" diff --git a/packages/wonder-blocks-clickable/package.json b/packages/wonder-blocks-clickable/package.json index 3fad91fe5..ff3e646db 100644 --- a/packages/wonder-blocks-clickable/package.json +++ b/packages/wonder-blocks-clickable/package.json @@ -21,10 +21,10 @@ }, "peerDependencies": { "aphrodite": "^1.2.5", - "react": "16.14.0", - "react-dom": "16.14.0", - "react-router": "5.2.1", - "react-router-dom": "5.3.0" + "react": "18.2.0", + "react-dom": "18.2.0", + "react-router": "5.3.4", + "react-router-dom": "5.3.4" }, "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1" diff --git a/packages/wonder-blocks-core/package.json b/packages/wonder-blocks-core/package.json index 99198087c..b16e4748b 100644 --- a/packages/wonder-blocks-core/package.json +++ b/packages/wonder-blocks-core/package.json @@ -17,10 +17,10 @@ }, "peerDependencies": { "aphrodite": "^1.2.5", - "react": "16.14.0", - "react-dom": "16.14.0", - "react-router": "5.2.1", - "react-router-dom": "5.3.0" + "react": "18.2.0", + "react-dom": "18.2.0", + "react-router": "5.3.4", + "react-router-dom": "5.3.4" }, "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1", diff --git a/packages/wonder-blocks-core/src/hooks/__tests__/use-force-update.test.tsx b/packages/wonder-blocks-core/src/hooks/__tests__/use-force-update.test.tsx index fc0877573..079008bdb 100644 --- a/packages/wonder-blocks-core/src/hooks/__tests__/use-force-update.test.tsx +++ b/packages/wonder-blocks-core/src/hooks/__tests__/use-force-update.test.tsx @@ -1,6 +1,5 @@ import * as React from "react"; -import {render, act} from "@testing-library/react"; -import {renderHook} from "@testing-library/react-hooks"; +import {render, act, renderHook, waitFor} from "@testing-library/react"; import {useForceUpdate} from "../use-force-update"; @@ -22,7 +21,7 @@ describe("#useForceUpdate", () => { jest.useFakeTimers(); }); - it("should cause component to render when invoked multiple times before a render", () => { + it("should cause component to render when invoked multiple times before a render", async () => { // Arrange const Component = (): React.ReactElement => { const countRef = React.useRef(0); @@ -49,20 +48,21 @@ describe("#useForceUpdate", () => { // Act const wrapper = render(); - act(() => { + await act(() => { // Advance enough for the timeout to run 4 times. // Which means the component should have rendered 4 times, // with one more pending for the timeout that was setup in // the last render. jest.advanceTimersByTime(204); }); - const result = wrapper.container.textContent; // Assert - expect(result).toBe("4"); + await waitFor(() => { + expect(wrapper.container.textContent).toBe("4"); + }); }); - it("should cause component to render each time it is invoked after a render", () => { + it("should cause component to render each time it is invoked after a render", async () => { // Arrange const Component = (): React.ReactElement => { const countRef = React.useRef(0); @@ -85,10 +85,11 @@ describe("#useForceUpdate", () => { // the last render. jest.advanceTimersByTime(204); }); - const result = wrapper.container.textContent; // Assert - expect(result).toBe("4"); + await waitFor(() => { + expect(wrapper.container.textContent).toBe("4"); + }); }); it("should cause a consuming hook to update without a render", async () => { @@ -107,15 +108,15 @@ describe("#useForceUpdate", () => { }; // Act - const {result, waitForNextUpdate} = renderHook(() => useTestHook()); + const {result} = renderHook(() => useTestHook()); const [, updateMe] = result.current; - act(() => updateMe()); - await waitForNextUpdate(); - act(() => updateMe()); - await waitForNextUpdate(); + await act(() => updateMe()); + await act(() => updateMe()); // Assert - expect(result.current[0]).toBe(2); + await waitFor(() => { + expect(result.current[0]).toBe(2); + }); }); }); }); diff --git a/packages/wonder-blocks-core/src/hooks/__tests__/use-is-mounted.test.tsx b/packages/wonder-blocks-core/src/hooks/__tests__/use-is-mounted.test.tsx index 1e939e7e9..96dd522c6 100644 --- a/packages/wonder-blocks-core/src/hooks/__tests__/use-is-mounted.test.tsx +++ b/packages/wonder-blocks-core/src/hooks/__tests__/use-is-mounted.test.tsx @@ -1,6 +1,5 @@ import * as React from "react"; -import {render, screen} from "@testing-library/react"; -import {renderHook} from "@testing-library/react-hooks"; +import {render, screen, renderHook} from "@testing-library/react"; import {renderToString} from "react-dom/server"; import Server from "../../util/server"; diff --git a/packages/wonder-blocks-core/src/hooks/__tests__/use-latest-ref.test.ts b/packages/wonder-blocks-core/src/hooks/__tests__/use-latest-ref.test.ts index f95b9fc83..44db729d1 100644 --- a/packages/wonder-blocks-core/src/hooks/__tests__/use-latest-ref.test.ts +++ b/packages/wonder-blocks-core/src/hooks/__tests__/use-latest-ref.test.ts @@ -1,4 +1,4 @@ -import {renderHook} from "@testing-library/react-hooks"; +import {renderHook} from "@testing-library/react"; import {useLatestRef} from "../use-latest-ref"; describe("useLatestRef", () => { diff --git a/packages/wonder-blocks-core/src/hooks/__tests__/use-on-mount-effect.test.ts b/packages/wonder-blocks-core/src/hooks/__tests__/use-on-mount-effect.test.ts index 1328bb76e..9a9d52f32 100644 --- a/packages/wonder-blocks-core/src/hooks/__tests__/use-on-mount-effect.test.ts +++ b/packages/wonder-blocks-core/src/hooks/__tests__/use-on-mount-effect.test.ts @@ -1,4 +1,4 @@ -import {renderHook} from "@testing-library/react-hooks"; +import {renderHook} from "@testing-library/react"; import {useOnMountEffect} from "../use-on-mount-effect"; diff --git a/packages/wonder-blocks-core/src/hooks/__tests__/use-online.test.tsx b/packages/wonder-blocks-core/src/hooks/__tests__/use-online.test.tsx index 2d54ad352..2663a95b4 100644 --- a/packages/wonder-blocks-core/src/hooks/__tests__/use-online.test.tsx +++ b/packages/wonder-blocks-core/src/hooks/__tests__/use-online.test.tsx @@ -1,8 +1,6 @@ // eslint-disable-next-line import/no-unassigned-import -import "jest-extended"; import * as React from "react"; -import {render, act as reactAct} from "@testing-library/react"; -import {renderHook} from "@testing-library/react-hooks"; +import {render, act as reactAct, renderHook} from "@testing-library/react"; import {useOnline} from "../use-online"; diff --git a/packages/wonder-blocks-core/src/hooks/__tests__/use-pre-hydration-effect.test.tsx b/packages/wonder-blocks-core/src/hooks/__tests__/use-pre-hydration-effect.test.tsx index 2ce4f9c44..c837ce1e2 100644 --- a/packages/wonder-blocks-core/src/hooks/__tests__/use-pre-hydration-effect.test.tsx +++ b/packages/wonder-blocks-core/src/hooks/__tests__/use-pre-hydration-effect.test.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import {renderHook} from "@testing-library/react-hooks"; +import {renderHook} from "@testing-library/react"; import {renderToString} from "react-dom/server"; import Server from "../../util/server"; @@ -18,6 +18,10 @@ jest.mock("react", () => { }); describe("usePreHydrationEffect", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + describe("client side mode", () => { beforeEach(() => { jest.spyOn(Server, "isServerSide").mockReturnValue(false); diff --git a/packages/wonder-blocks-core/src/hooks/__tests__/use-render-state.test.tsx b/packages/wonder-blocks-core/src/hooks/__tests__/use-render-state.test.tsx index 9a95c236c..a21fbdb54 100644 --- a/packages/wonder-blocks-core/src/hooks/__tests__/use-render-state.test.tsx +++ b/packages/wonder-blocks-core/src/hooks/__tests__/use-render-state.test.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import {renderHookStatic} from "@khanacademy/wonder-blocks-testing-core"; -import {renderHook} from "@testing-library/react-hooks"; +import {render} from "@testing-library/react"; import {useRenderState} from "../use-render-state"; import {RenderStateRoot} from "../../components/render-state-root"; @@ -25,32 +25,46 @@ describe("useRenderState", () => { describe("client-side rendering", () => { test("first render returns RenderState.Initial", () => { // Arrange - const wrapper = ({children}: any) => ( - {children} - ); + const mockRenderState = jest.fn(); + + const UnderTest = () => { + const renderState = useRenderState(); + // Mock the render state so we can test every state change + return mockRenderState(renderState); + }; // Act - const {result} = renderHook(() => useRenderState(), { - wrapper, + render(, { + wrapper: RenderStateRoot, }); // Assert - expect(result.all[0]).toEqual(RenderState.Initial); + expect(mockRenderState).toHaveBeenNthCalledWith( + 1, + RenderState.Initial, + ); }); - test("second render returns RenderState.Standard", () => { + test("second render returns RenderState.Standard", async () => { // Arrange - const wrapper = ({children}: any) => ( - {children} - ); + const mockRenderState = jest.fn(); + + const UnderTest = () => { + const renderState = useRenderState(); + // Mock the render state so we can test every state change + return mockRenderState(renderState); + }; // Act - const {result} = renderHook(() => useRenderState(), { - wrapper, + render(, { + wrapper: RenderStateRoot, }); // Assert - expect(result.all[1]).toEqual(RenderState.Standard); + expect(mockRenderState).toHaveBeenNthCalledWith( + 2, + RenderState.Standard, + ); }); }); }); diff --git a/packages/wonder-blocks-core/src/util/add-style.tsx b/packages/wonder-blocks-core/src/util/add-style.tsx index 7b928a9f9..a5015cec6 100644 --- a/packages/wonder-blocks-core/src/util/add-style.tsx +++ b/packages/wonder-blocks-core/src/util/add-style.tsx @@ -22,17 +22,18 @@ export default function addStyle< React.RefAttributes< // We need to lookup the HTML/SVG element type based on the tag name, but only // for JSX intrinsics (aka HTML/SVG tags). - T extends keyof JSX.IntrinsicElements ? IntrinsicElementsMap[T] : T + T extends keyof IntrinsicElementsMap ? IntrinsicElementsMap[T] : T > > { return React.forwardRef< // We need to lookup the HTML/SVG element type based on the tag name, but only // for JSX intrinsics (aka HTML/SVG tags). - T extends keyof JSX.IntrinsicElements ? IntrinsicElementsMap[T] : T, + T extends keyof IntrinsicElementsMap ? IntrinsicElementsMap[T] : T, Props >((props, ref) => { - // eslint-disable-next-line react/prop-types - const {className, style, ...otherProps} = props; + // NOTE: Cast as any here because our types are too comlicated for + // TypeScript to properly understand them. + const {className, style, ...otherProps} = props as any; const reset = typeof Component === "string" ? overrides[Component] : null; @@ -40,9 +41,6 @@ export default function addStyle< processStyleList([reset, defaultStyle, style]); return ( - // @ts-expect-error: TS says this is not assignable to the return forwardRef()'s return type. - // Type 'Omit, "style" | "className"> & { ref: ForwardedRef; className: string; style: CSSProperties; }' is not assignable to type 'IntrinsicAttributes & LibraryManagedAttributes & ClassAttributes & ... 179 more ... & SVGProps<...>>'. - // Type 'Omit, "style" | "className"> & { ref: ForwardedRef; className: string; style: CSSProperties; }' is not assignable to type 'LibraryManagedAttributes & ClassAttributes & ObjectHTMLAttributes & ... 178 more ... & SVGProps<...>>' { // Arrange const useRequestInterceptSpy = jest .spyOn(UseRequestInterception, "useRequestInterception") - .mockReturnValue(jest.fn()); + .mockReturnValue(jest.fn().mockResolvedValue("data")); const fakeHandler = jest.fn(); // Act @@ -301,7 +302,7 @@ describe("#useCachedEffect", () => { "should fulfill request when there is no cached value and FetchPolicy.%s", (fetchPolicy: any) => { // Arrange - const fakeHandler = jest.fn(); + const fakeHandler = jest.fn().mockResolvedValue("data"); jest.spyOn(UseSharedCache, "useSharedCache").mockReturnValue([ null, jest.fn(), @@ -321,7 +322,7 @@ describe("#useCachedEffect", () => { "should fulfill request when there is a cached value and FetchPolicy.%s", (fetchPolicy: any) => { // Arrange - const fakeHandler = jest.fn(); + const fakeHandler = jest.fn().mockResolvedValue("data"); jest.spyOn(UseSharedCache, "useSharedCache").mockReturnValue([ Status.success("data"), jest.fn(), @@ -397,14 +398,15 @@ describe("#useCachedEffect", () => { const fakeHandler = jest.fn().mockResolvedValue("data"); // Act - const {rerender, waitForNextUpdate} = clientRenderHook(() => + const {rerender} = clientRenderHook(() => useCachedEffect("ID", fakeHandler), ); rerender(); - await waitForNextUpdate(); // Assert - expect(fakeHandler).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(fakeHandler).toHaveBeenCalledTimes(1); + }); }); it("should fulfill request again if requestId changes", async () => { @@ -412,17 +414,18 @@ describe("#useCachedEffect", () => { const fakeHandler = jest.fn().mockResolvedValue("data"); // Act - const {rerender, waitForNextUpdate} = clientRenderHook( + const {rerender} = clientRenderHook( ({requestId}: any) => useCachedEffect(requestId, fakeHandler), { initialProps: {requestId: "ID"}, }, ); rerender({requestId: "ID2"}); - await waitForNextUpdate(); // Assert - expect(fakeHandler).toHaveBeenCalledTimes(2); + await waitFor(() => { + expect(fakeHandler).toHaveBeenCalledTimes(2); + }); }); it("should update shared cache with result when request is fulfilled", async () => { @@ -435,13 +438,12 @@ describe("#useCachedEffect", () => { const fakeHandler = jest.fn().mockResolvedValue("DATA"); // Act - const {waitForNextUpdate} = clientRenderHook(() => - useCachedEffect("ID", fakeHandler), - ); - await waitForNextUpdate(); + clientRenderHook(() => useCachedEffect("ID", fakeHandler)); // Assert - expect(setCacheFn).toHaveBeenCalledWith(Status.success("DATA")); + await waitFor(() => { + expect(setCacheFn).toHaveBeenCalledWith(Status.success("DATA")); + }); }); it("should ignore inflight request if requestId changes", async () => { @@ -464,7 +466,7 @@ describe("#useCachedEffect", () => { await act((): Promise => Promise.all([response1, response2])); // Assert - expect(result.all).not.toContainEqual(Status.success("DATA1")); + expect(result.current).not.toContainEqual(Status.success("DATA1")); }); it("should return result of fulfilled request for current requestId", async () => { @@ -519,7 +521,7 @@ describe("#useCachedEffect", () => { await act(() => response1); // Assert - expect(result.all).not.toContainEqual(Status.success("DATA1")); + expect(result.current).not.toContainEqual(Status.success("DATA1")); }); it("should not ignore result of inflight request if handler changes", async () => { @@ -576,11 +578,7 @@ describe("#useCachedEffect", () => { .mockReturnValueOnce(response2); // Act - const { - rerender, - result: hookResult, - waitForNextUpdate, - } = clientRenderHook( + const {rerender, result: hookResult} = clientRenderHook( ({requestId}: any) => useCachedEffect(requestId, fakeHandler, { retainResultOnChange: true, @@ -592,10 +590,11 @@ describe("#useCachedEffect", () => { await act(() => response1); rerender({requestId: "ID2"}); const [result] = hookResult.current; - await waitForNextUpdate(); // Assert - expect(result).toStrictEqual(Status.success("DATA1")); + await waitFor(() => { + expect(result).toStrictEqual(Status.success("DATA1")); + }); }); it("should return loading status when requestId changes and retainResultOnChange is false", async () => { @@ -670,7 +669,7 @@ describe("#useCachedEffect", () => { // Act render(); - await reactAct(() => response); + await act(() => response); // Assert expect(renderCount).toBe(2); @@ -700,7 +699,7 @@ describe("#useCachedEffect", () => { // Act render(); - await reactAct(() => response); + await act(() => response); // Assert expect(renderCount).toBe(2); @@ -725,7 +724,7 @@ describe("#useCachedEffect", () => { // Act render(); - await reactAct(() => response); + await act(() => response); // Assert expect(renderCount).toBe(1); diff --git a/packages/wonder-blocks-data/src/hooks/__tests__/use-gql-router-context.test.tsx b/packages/wonder-blocks-data/src/hooks/__tests__/use-gql-router-context.test.tsx index 43772a8fd..d7f91196f 100644 --- a/packages/wonder-blocks-data/src/hooks/__tests__/use-gql-router-context.test.tsx +++ b/packages/wonder-blocks-data/src/hooks/__tests__/use-gql-router-context.test.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import {renderHook} from "@testing-library/react-hooks"; +import {renderHook} from "@testing-library/react"; import {GqlRouterContext} from "../../util/gql-router-context"; import {useGqlRouterContext} from "../use-gql-router-context"; @@ -9,14 +9,10 @@ describe("#useGqlRouterContext", () => { // Arrange // Act - const { - result: {error: result}, - } = renderHook(() => useGqlRouterContext()); + const underTest = () => renderHook(() => useGqlRouterContext()); // Assert - expect(result).toMatchInlineSnapshot( - `[InternalGqlError: No GqlRouter]`, - ); + expect(underTest).toThrowErrorMatchingInlineSnapshot(`"No GqlRouter"`); }); it("should return an equivalent to the GqlRouterContext if no overrides given", () => { diff --git a/packages/wonder-blocks-data/src/hooks/__tests__/use-gql.test.tsx b/packages/wonder-blocks-data/src/hooks/__tests__/use-gql.test.tsx index 5da7e3f2e..d240965cc 100644 --- a/packages/wonder-blocks-data/src/hooks/__tests__/use-gql.test.tsx +++ b/packages/wonder-blocks-data/src/hooks/__tests__/use-gql.test.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import {renderHook} from "@testing-library/react-hooks"; +import {renderHook} from "@testing-library/react"; import * as GetGqlDataFromResponse from "../../util/get-gql-data-from-response"; import {GqlRouterContext} from "../../util/gql-router-context"; @@ -14,14 +14,10 @@ describe("#useGql", () => { // Arrange // Act - const { - result: {error: result}, - } = renderHook(() => useGql()); + const underTest = () => renderHook(() => useGql()); // Assert - expect(result).toMatchInlineSnapshot( - `[InternalGqlError: No GqlRouter]`, - ); + expect(underTest).toThrowErrorMatchingInlineSnapshot(`"No GqlRouter"`); }); it("should return a function", () => { diff --git a/packages/wonder-blocks-data/src/hooks/__tests__/use-hydratable-effect.test.ts b/packages/wonder-blocks-data/src/hooks/__tests__/use-hydratable-effect.test.ts index 3b48bff75..8d26dca6a 100644 --- a/packages/wonder-blocks-data/src/hooks/__tests__/use-hydratable-effect.test.ts +++ b/packages/wonder-blocks-data/src/hooks/__tests__/use-hydratable-effect.test.ts @@ -1,8 +1,5 @@ import * as React from "react"; -import { - renderHook as clientRenderHook, - act, -} from "@testing-library/react-hooks"; +import {renderHook, act, waitFor} from "@testing-library/react"; import {renderHookStatic} from "@khanacademy/wonder-blocks-testing-core"; import {Server} from "@khanacademy/wonder-blocks-core"; @@ -205,10 +202,10 @@ describe("#useHydratableEffect", () => { const useServerEffectSpy = jest .spyOn(UseServerEffect, "useServerEffect") .mockReturnValue(null); - const fakeHandler = jest.fn(); + const fakeHandler = jest.fn().mockResolvedValue("data"); // Act - clientRenderHook(() => + renderHook(() => useHydratableEffect("ID", fakeHandler, { clientBehavior, }), @@ -225,13 +222,13 @@ describe("#useHydratableEffect", () => { it("should fulfill request when there is no server value to hydrate", () => { // Arrange - const fakeHandler = jest.fn(); + const fakeHandler = jest.fn().mockResolvedValue("data"); jest.spyOn(UseServerEffect, "useServerEffect").mockReturnValue( null, ); // Act - clientRenderHook(() => useHydratableEffect("ID", fakeHandler)); + renderHook(() => useHydratableEffect("ID", fakeHandler)); // Assert expect(fakeHandler).toHaveBeenCalled(); @@ -245,8 +242,8 @@ describe("#useHydratableEffect", () => { const fakeHandler = jest.fn().mockReturnValue(pending); // Act - clientRenderHook(() => useHydratableEffect("ID", fakeHandler)); - clientRenderHook(() => useHydratableEffect("ID", fakeHandler)); + renderHook(() => useHydratableEffect("ID", fakeHandler)); + renderHook(() => useHydratableEffect("ID", fakeHandler)); // Assert expect(fakeHandler).toHaveBeenCalledTimes(1); @@ -260,13 +257,13 @@ describe("#useHydratableEffect", () => { "should fulfill request when server value is $serverResult and clientBehavior is ExecuteWhenNoSuccessResult", ({serverResult}: any) => { // Arrange - const fakeHandler = jest.fn(); + const fakeHandler = jest.fn().mockResolvedValue("data"); jest.spyOn(UseServerEffect, "useServerEffect").mockReturnValue( serverResult, ); // Act - clientRenderHook(() => + renderHook(() => useHydratableEffect("ID", fakeHandler, { clientBehavior: WhenClientSide.ExecuteWhenNoSuccessResult, @@ -287,13 +284,13 @@ describe("#useHydratableEffect", () => { "should fulfill request when server value is $serveResult and clientBehavior is AlwaysExecute", ({serverResult}: any) => { // Arrange - const fakeHandler = jest.fn(); + const fakeHandler = jest.fn().mockResolvedValue("data"); jest.spyOn(UseServerEffect, "useServerEffect").mockReturnValue( serverResult, ); // Act - clientRenderHook(() => + renderHook(() => useHydratableEffect("ID", fakeHandler, { clientBehavior: WhenClientSide.AlwaysExecute, }), @@ -312,7 +309,7 @@ describe("#useHydratableEffect", () => { ); // Act - clientRenderHook(() => + renderHook(() => useHydratableEffect("ID", fakeHandler, { clientBehavior: WhenClientSide.ExecuteWhenNoSuccessResult, }), @@ -335,7 +332,7 @@ describe("#useHydratableEffect", () => { ); // Act - clientRenderHook(() => + renderHook(() => useHydratableEffect("ID", fakeHandler, { clientBehavior: WhenClientSide.ExecuteWhenNoResult, }), @@ -353,14 +350,15 @@ describe("#useHydratableEffect", () => { ); // Act - const {rerender, waitForNextUpdate} = clientRenderHook(() => + const {rerender} = renderHook(() => useHydratableEffect("ID", fakeHandler), ); rerender(); - await waitForNextUpdate(); // Assert - expect(fakeHandler).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(fakeHandler).toHaveBeenCalledTimes(1); + }); }); it("should fulfill request again if requestId changes", async () => { @@ -371,7 +369,7 @@ describe("#useHydratableEffect", () => { ); // Act - const {rerender, waitForNextUpdate} = clientRenderHook( + const {rerender} = renderHook( ({requestId}: any) => useHydratableEffect(requestId, fakeHandler), { @@ -379,10 +377,11 @@ describe("#useHydratableEffect", () => { }, ); rerender({requestId: "ID2"}); - await waitForNextUpdate(); // Assert - expect(fakeHandler).toHaveBeenCalledTimes(2); + await waitFor(() => { + expect(fakeHandler).toHaveBeenCalledTimes(2); + }); }); it("should default shared cache to hydrate value for new requestId", () => { @@ -397,7 +396,7 @@ describe("#useHydratableEffect", () => { .mockReturnValueOnce(Status.success("GOODDATA")); // Act - const {rerender, result} = clientRenderHook( + const {rerender, result} = renderHook( ({requestId}: any) => useHydratableEffect(requestId, fakeHandler), { @@ -420,13 +419,12 @@ describe("#useHydratableEffect", () => { const fakeHandler = jest.fn().mockResolvedValue("DATA"); // Act - const {waitForNextUpdate} = clientRenderHook(() => - useHydratableEffect("ID", fakeHandler), - ); - await waitForNextUpdate(); + renderHook(() => useHydratableEffect("ID", fakeHandler)); // Assert - expect(setCacheFn).toHaveBeenCalledWith(Status.success("DATA")); + await waitFor(() => { + expect(setCacheFn).toHaveBeenCalledWith(Status.success("DATA")); + }); }); it("should ignore inflight request if requestId changes", async () => { @@ -442,7 +440,7 @@ describe("#useHydratableEffect", () => { ); // Act - const {rerender, result} = clientRenderHook( + const {rerender, result} = renderHook( ({requestId}: any) => useHydratableEffect(requestId, fakeHandler), { @@ -453,7 +451,12 @@ describe("#useHydratableEffect", () => { await act((): Promise => Promise.all([response1, response2])); // Assert - expect(result.all).not.toContainEqual(Status.success("DATA1")); + // TODO(juan): See if this needs to be fixed. + await waitFor(() => { + expect(result.current).not.toContainEqual( + Status.success("DATA1"), + ); + }); }); it("should return result of fulfilled request for current requestId", async () => { @@ -469,7 +472,7 @@ describe("#useHydratableEffect", () => { ); // Act - const {rerender, result} = clientRenderHook( + const {rerender, result} = renderHook( ({requestId}: any) => useHydratableEffect(requestId, fakeHandler), { @@ -491,7 +494,7 @@ describe("#useHydratableEffect", () => { ); // Act - clientRenderHook(() => + renderHook(() => useHydratableEffect("ID", fakeHandler, {skip: true}), ); @@ -508,7 +511,7 @@ describe("#useHydratableEffect", () => { ); // Act - const {rerender, result} = clientRenderHook( + const {rerender, result} = renderHook( ({skip}: any) => useHydratableEffect("ID", fakeHandler, {skip}), { initialProps: {skip: false}, @@ -519,7 +522,12 @@ describe("#useHydratableEffect", () => { await act((): Promise => response1); // Assert - expect(result.all).not.toContainEqual(Status.success("DATA1")); + // TODO(juan): See if this needs to be fixed. + await waitFor(() => { + expect(result.current).not.toContainEqual( + Status.success("DATA1"), + ); + }); }); it("should not ignore inflight request if handler changes", async () => { @@ -533,7 +541,7 @@ describe("#useHydratableEffect", () => { ); // Act - const {rerender, result} = clientRenderHook( + const {rerender, result} = renderHook( ({handler}: any) => useHydratableEffect("ID", handler), { initialProps: {handler: fakeHandler1}, @@ -555,7 +563,7 @@ describe("#useHydratableEffect", () => { ); // Act - const {rerender, result} = clientRenderHook( + const {rerender, result} = renderHook( ({options}: any) => useHydratableEffect("ID", fakeHandler), { initialProps: {options: undefined}, @@ -586,11 +594,7 @@ describe("#useHydratableEffect", () => { ); // Act - const { - rerender, - result: hookResult, - waitForNextUpdate, - } = clientRenderHook( + const {rerender, result: hookResult} = renderHook( ({requestId}: any) => useHydratableEffect(requestId, fakeHandler, { retainResultOnChange: true, @@ -603,10 +607,11 @@ describe("#useHydratableEffect", () => { await act((): Promise => response1); rerender({requestId: "ID2"}); const result = hookResult.current; - await waitForNextUpdate(); // Assert - expect(result).toStrictEqual(Status.success("DATA1")); + await waitFor(() => { + expect(result).toStrictEqual(Status.success("DATA1")); + }); }); it("should return loading status when requestId changes and retainResultOnChange is false", async () => { @@ -624,7 +629,7 @@ describe("#useHydratableEffect", () => { ); // Act - const {rerender, result} = clientRenderHook( + const {rerender, result} = renderHook( ({requestId}: any) => useHydratableEffect(requestId, fakeHandler, { retainResultOnChange: false, @@ -650,7 +655,7 @@ describe("#useHydratableEffect", () => { ); // Act - const {result} = clientRenderHook(() => + const {result} = renderHook(() => useHydratableEffect("ID", fakeHandler), ); @@ -669,7 +674,7 @@ describe("#useHydratableEffect", () => { ); // Act - const {result} = clientRenderHook(() => + const {result} = renderHook(() => useHydratableEffect("ID", fakeHandler, { onResultChanged: () => {}, }), @@ -691,7 +696,7 @@ describe("#useHydratableEffect", () => { const onResultChanged = jest.fn(); // Act - clientRenderHook(() => + renderHook(() => useHydratableEffect("ID", fakeHandler, { onResultChanged, }), diff --git a/packages/wonder-blocks-data/src/hooks/__tests__/use-request-interception.test.tsx b/packages/wonder-blocks-data/src/hooks/__tests__/use-request-interception.test.tsx index b841af8da..4f8667c9e 100644 --- a/packages/wonder-blocks-data/src/hooks/__tests__/use-request-interception.test.tsx +++ b/packages/wonder-blocks-data/src/hooks/__tests__/use-request-interception.test.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import {renderHook} from "@testing-library/react-hooks"; +import {render, renderHook} from "@testing-library/react"; import InterceptRequests from "../../components/intercept-requests"; import {useRequestInterception} from "../use-request-interception"; @@ -63,25 +63,31 @@ describe("#useRequestInterception", () => { expect(result1).not.toBe(result2); }); - it("should return a new function if the context changes", () => { + it("should return a new function if the context changes", async () => { // Arrange + const captureHookResult = jest.fn(); const handler = jest.fn(); - const interceptor1 = jest.fn(); - const interceptor2 = jest.fn(); - const Wrapper = ({children, interceptor}: any): React.ReactElement => ( - - {children} - - ); + const interceptor1 = jest.fn().mockResolvedValue("INTERCEPTED_DATA1"); + const interceptor2 = jest.fn().mockResolvedValue("INTERCEPTED_DATA2"); + const HookWrapper = (): React.ReactElement => { + const resultToCapture = useRequestInterception("ID", handler); + captureHookResult(resultToCapture); + return <>; + }; // Act - const wrapper = renderHook( - () => useRequestInterception("ID", handler), - {wrapper: Wrapper, initialProps: {interceptor: interceptor1}}, + const wrapper = render( + + + , ); - const result1 = wrapper.result.current; - wrapper.rerender({interceptor: interceptor2}); - const result2 = wrapper.result.current; + const result1 = captureHookResult.mock.calls[0][0]; + wrapper.rerender( + + + , + ); + const result2 = captureHookResult.mock.calls[1][0]; // Assert expect(result1).not.toBe(result2); diff --git a/packages/wonder-blocks-data/src/hooks/__tests__/use-server-effect.test.ts b/packages/wonder-blocks-data/src/hooks/__tests__/use-server-effect.test.ts index 172359955..d2f2dcf33 100644 --- a/packages/wonder-blocks-data/src/hooks/__tests__/use-server-effect.test.ts +++ b/packages/wonder-blocks-data/src/hooks/__tests__/use-server-effect.test.ts @@ -1,5 +1,8 @@ -import {renderHook as clientRenderHook} from "@testing-library/react-hooks"; -import {renderHookStatic} from "@khanacademy/wonder-blocks-testing-core"; +import {renderHook} from "@testing-library/react"; +import { + renderHookStatic, + testHarness, +} from "@khanacademy/wonder-blocks-testing-core"; import {Server} from "@khanacademy/wonder-blocks-core"; @@ -208,7 +211,7 @@ describe("#useServerEffect", () => { // Act const { result: {current: result}, - } = clientRenderHook(() => useServerEffect("ID", fakeHandler)); + } = renderHook(() => useServerEffect("ID", fakeHandler)); // Assert expect(result).toBeNull(); @@ -225,7 +228,7 @@ describe("#useServerEffect", () => { // Act const { result: {current: result}, - } = clientRenderHook(() => useServerEffect("ID", fakeHandler)); + } = renderHook(() => useServerEffect("ID", fakeHandler)); // Assert expect(result).toEqual({status: "success", data: "DATA"}); @@ -241,7 +244,7 @@ describe("#useServerEffect", () => { // Act const { result: {current: result}, - } = clientRenderHook(() => useServerEffect("ID", fakeHandler)); + } = renderHook(() => useServerEffect("ID", fakeHandler)); // Assert expect(result).toEqual({ @@ -261,10 +264,14 @@ describe("#useServerEffect", () => { RequestTracker.Default, "trackDataRequest", ); + const HarnessedTrackData = testHarness(TrackData, { + // We don't care about the error that can get thrown. + boundary: jest.fn(), + }); // Act - clientRenderHook(() => useServerEffect("ID", fakeHandler), { - wrapper: TrackData, + renderHook(() => useServerEffect("ID", fakeHandler), { + wrapper: HarnessedTrackData, }); // Assert @@ -284,7 +291,7 @@ describe("#useServerEffect", () => { ); // Act - clientRenderHook(() => useServerEffect("ID", fakeHandler)); + renderHook(() => useServerEffect("ID", fakeHandler)); // Assert expect(fulfillRequestSpy).not.toHaveBeenCalled(); diff --git a/packages/wonder-blocks-data/src/hooks/__tests__/use-shared-cache.test.ts b/packages/wonder-blocks-data/src/hooks/__tests__/use-shared-cache.test.ts index 94d1cd8d9..5feccfbe3 100644 --- a/packages/wonder-blocks-data/src/hooks/__tests__/use-shared-cache.test.ts +++ b/packages/wonder-blocks-data/src/hooks/__tests__/use-shared-cache.test.ts @@ -1,6 +1,5 @@ -// eslint-disable-next-line import/no-unassigned-import -import "jest-extended"; -import {renderHook as clientRenderHook} from "@testing-library/react-hooks"; +import {renderHook} from "@testing-library/react"; +import {hookHarness} from "@khanacademy/wonder-blocks-testing-core"; import {useSharedCache, SharedCache} from "../use-shared-cache"; @@ -17,12 +16,16 @@ describe("#useSharedCache", () => { ${() => "BOO"} `("should throw if the id is $id", ({id}: any) => { // Arrange + const captureErrorFn = jest.fn(); // Act - const {result} = clientRenderHook(() => useSharedCache(id, "scope")); + renderHook(() => useSharedCache(id, "scope"), { + wrapper: hookHarness({boundary: captureErrorFn}), + }); + const result = captureErrorFn.mock.calls[0][0]; // Assert - expect(result.error).toMatchSnapshot(); + expect(result).toMatchSnapshot(); }); it.each` @@ -33,12 +36,16 @@ describe("#useSharedCache", () => { ${() => "BOO"} `("should throw if the scope is $scope", ({scope}: any) => { // Arrange + const captureErrorFn = jest.fn(); // Act - const {result} = clientRenderHook(() => useSharedCache("id", scope)); + renderHook(() => useSharedCache("id", scope), { + wrapper: hookHarness({boundary: captureErrorFn}), + }); + const result = captureErrorFn.mock.calls[0][0]; // Assert - expect(result.error).toMatchSnapshot(); + expect(result).toMatchSnapshot(); }); it("should return a tuple of two items", () => { @@ -47,7 +54,7 @@ describe("#useSharedCache", () => { // Act const { result: {current: result}, - } = clientRenderHook(() => useSharedCache("id", "scope")); + } = renderHook(() => useSharedCache("id", "scope")); // Assert expect(result).toBeArrayOfSize(2); @@ -60,7 +67,7 @@ describe("#useSharedCache", () => { // Act const { result: {current: result}, - } = clientRenderHook(() => useSharedCache("id", "scope")); + } = renderHook(() => useSharedCache("id", "scope")); // Assert expect(result[0]).toBeNull(); @@ -72,7 +79,7 @@ describe("#useSharedCache", () => { // Act const { result: {current: result}, - } = clientRenderHook(() => + } = renderHook(() => useSharedCache("id", "scope", "INITIAL VALUE"), ); @@ -86,7 +93,7 @@ describe("#useSharedCache", () => { // Act const { result: {current: result}, - } = clientRenderHook(() => + } = renderHook(() => useSharedCache("id", "scope", () => "INITIAL VALUE"), ); @@ -102,7 +109,7 @@ describe("#useSharedCache", () => { // Act const { result: {current: result}, - } = clientRenderHook(() => useSharedCache("id", "scope")); + } = renderHook(() => useSharedCache("id", "scope")); // Assert expect(result[1]).toBeFunction(); @@ -110,17 +117,17 @@ describe("#useSharedCache", () => { it("should be the same function if the id and scope remain the same", () => { // Arrange - const wrapper = clientRenderHook( + const wrapper = renderHook( ({id, scope}: any) => useSharedCache(id, scope), {initialProps: {id: "id", scope: "scope"}}, ); + const value1 = wrapper.result.current; // Act wrapper.rerender({ id: "id", scope: "scope", }); - const value1 = wrapper.result.all[wrapper.result.all.length - 2]; const value2 = wrapper.result.current; const result1 = Array.isArray(value1) ? value1[1] : "BAD1"; const result2 = Array.isArray(value2) ? value2[1] : "BAD2"; @@ -131,16 +138,16 @@ describe("#useSharedCache", () => { it("should be a new function if the id changes", () => { // Arrange - const wrapper = clientRenderHook( + const wrapper = renderHook( ({id}: any) => useSharedCache(id, "scope"), { initialProps: {id: "id"}, }, ); + const value1 = wrapper.result.current; // Act wrapper.rerender({id: "new-id"}); - const value1 = wrapper.result.all[wrapper.result.all.length - 2]; const value2 = wrapper.result.current; const result1 = Array.isArray(value1) ? value1[1] : "BAD1"; const result2 = Array.isArray(value2) ? value2[1] : "BAD2"; @@ -151,16 +158,16 @@ describe("#useSharedCache", () => { it("should be a new function if the scope changes", () => { // Arrange - const wrapper = clientRenderHook( + const wrapper = renderHook( ({scope}: any) => useSharedCache("id", scope), { initialProps: {scope: "scope"}, }, ); + const value1 = wrapper.result.current; // Act wrapper.rerender({scope: "new-scope"}); - const value1 = wrapper.result.all[wrapper.result.all.length - 2]; const value2 = wrapper.result.current; const result1 = Array.isArray(value1) ? value1[1] : "BAD1"; const result2 = Array.isArray(value2) ? value2[1] : "BAD2"; @@ -171,9 +178,7 @@ describe("#useSharedCache", () => { it("should set the value in the cache", () => { // Arrange - const wrapper = clientRenderHook(() => - useSharedCache("id", "scope"), - ); + const wrapper = renderHook(() => useSharedCache("id", "scope")); const setValue = wrapper.result.current[1]; // Act @@ -192,9 +197,7 @@ describe("#useSharedCache", () => { ${null} `("should purge the value from the cache if $value", ({value}: any) => { // Arrange - const wrapper = clientRenderHook(() => - useSharedCache("id", "scope"), - ); + const wrapper = renderHook(() => useSharedCache("id", "scope")); const setValue = wrapper.result.current[1]; setValue("CACHED_VALUE"); @@ -213,8 +216,8 @@ describe("#useSharedCache", () => { it("should share cache across all uses", () => { // Arrange - const hook1 = clientRenderHook(() => useSharedCache("id", "scope")); - const hook2 = clientRenderHook(() => useSharedCache("id", "scope")); + const hook1 = renderHook(() => useSharedCache("id", "scope")); + const hook2 = renderHook(() => useSharedCache("id", "scope")); hook1.result.current[1]("VALUE_1"); // Act @@ -231,8 +234,8 @@ describe("#useSharedCache", () => { ${"id2"} `("should not share cache if scope is different", ({id}: any) => { // Arrange - const hook1 = clientRenderHook(() => useSharedCache("id1", "scope1")); - const hook2 = clientRenderHook(() => useSharedCache(id, "scope2")); + const hook1 = renderHook(() => useSharedCache("id1", "scope1")); + const hook2 = renderHook(() => useSharedCache(id, "scope2")); hook1.result.current[1]("VALUE_1"); // Act @@ -249,8 +252,8 @@ describe("#useSharedCache", () => { ${"scope2"} `("should not share cache if id is different", ({scope}: any) => { // Arrange - const hook1 = clientRenderHook(() => useSharedCache("id1", "scope1")); - const hook2 = clientRenderHook(() => useSharedCache("id2", scope)); + const hook1 = renderHook(() => useSharedCache("id1", "scope1")); + const hook2 = renderHook(() => useSharedCache("id2", scope)); hook1.result.current[1]("VALUE_1"); // Act diff --git a/packages/wonder-blocks-data/src/util/__tests__/request-api.test.ts b/packages/wonder-blocks-data/src/util/__tests__/request-api.test.ts index 4162ad478..01d86abc1 100644 --- a/packages/wonder-blocks-data/src/util/__tests__/request-api.test.ts +++ b/packages/wonder-blocks-data/src/util/__tests__/request-api.test.ts @@ -1,5 +1,4 @@ // eslint-disable-next-line import/no-unassigned-import -import "jest-extended"; import {jest as wsJest} from "@khanacademy/wonder-stuff-testing"; import {Server} from "@khanacademy/wonder-blocks-core"; import {RequestFulfillment} from "../request-fulfillment"; diff --git a/packages/wonder-blocks-dropdown/package.json b/packages/wonder-blocks-dropdown/package.json index 949142538..76f1af271 100644 --- a/packages/wonder-blocks-dropdown/package.json +++ b/packages/wonder-blocks-dropdown/package.json @@ -32,12 +32,12 @@ "@phosphor-icons/core": "^2.0.2", "@popperjs/core": "^2.10.1", "aphrodite": "^1.2.5", - "react": "16.14.0", - "react-dom": "16.14.0", + "react": "18.2.0", + "react-dom": "18.2.0", "react-popper": "^2.0.0", - "react-router": "5.2.1", - "react-router-dom": "5.3.0", - "react-window": "^1.8.5" + "react-router": "5.3.4", + "react-router-dom": "5.3.4", + "react-window": "^1.8.10" }, "devDependencies": { "@khanacademy/wonder-blocks-button": "^6.3.12", diff --git a/packages/wonder-blocks-dropdown/src/components/__tests__/action-menu.test.tsx b/packages/wonder-blocks-dropdown/src/components/__tests__/action-menu.test.tsx index d113deb3e..a2d999cfd 100644 --- a/packages/wonder-blocks-dropdown/src/components/__tests__/action-menu.test.tsx +++ b/packages/wonder-blocks-dropdown/src/components/__tests__/action-menu.test.tsx @@ -314,10 +314,9 @@ describe("ActionMenu", () => { // Arrange const showDeleteAction = false; render( - // @ts-expect-error [FEI-5019] - TS2769 - No overload matches this call. - {showDeleteAction && } + {(showDeleteAction as any) && } , ); diff --git a/packages/wonder-blocks-dropdown/src/components/__tests__/multi-select.test.tsx b/packages/wonder-blocks-dropdown/src/components/__tests__/multi-select.test.tsx index b76dc2d5d..d3e43108b 100644 --- a/packages/wonder-blocks-dropdown/src/components/__tests__/multi-select.test.tsx +++ b/packages/wonder-blocks-dropdown/src/components/__tests__/multi-select.test.tsx @@ -444,11 +444,11 @@ describe("MultiSelect", () => { // Act // Grab the second item in the list - const item = screen.getByRole("option", { + const item = await screen.findByRole("option", { name: "item 2", hidden: true, }); - userEvent.click(item); + await userEvent.click(item); // Assert expect(item).toHaveAttribute("aria-selected", "true"); diff --git a/packages/wonder-blocks-dropdown/src/components/__tests__/single-select.test.tsx b/packages/wonder-blocks-dropdown/src/components/__tests__/single-select.test.tsx index 1fd2fbb1b..2a4d95ffa 100644 --- a/packages/wonder-blocks-dropdown/src/components/__tests__/single-select.test.tsx +++ b/packages/wonder-blocks-dropdown/src/components/__tests__/single-select.test.tsx @@ -332,7 +332,16 @@ describe("SingleSelect", () => { expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); }); - it("should find and select an item using the keyboard", async () => { + /* + The keyboard events (I tried .keyboard and .type) are not working as + needed. From what I can tell, they are going to the wrong element or + otherwise not getting handled as they would in a non-test world. + We had this issue with elsewhere too and haven't resolved it (since + updating to UserEvents v14, it seems). Skipping this test for now + until we can work out how to replicate things again. This could be + changed to a storybook test perhaps. + */ + it.skip("should find and select an item using the keyboard", async () => { // Arrange const userEvent = doRender( diff --git a/packages/wonder-blocks-dropdown/src/components/dropdown-core.tsx b/packages/wonder-blocks-dropdown/src/components/dropdown-core.tsx index 58643887e..366ef1f71 100644 --- a/packages/wonder-blocks-dropdown/src/components/dropdown-core.tsx +++ b/packages/wonder-blocks-dropdown/src/components/dropdown-core.tsx @@ -262,23 +262,10 @@ type State = Readonly<{ * in overflow: auto containers. */ class DropdownCore extends React.Component { - // Keeps track of the index of the focused item, out of a list of focusable items - // @ts-expect-error [FEI-5019] - TS2564 - Property 'focusedIndex' has no initializer and is not definitely assigned in the constructor. - focusedIndex: number; - // Keeps track of the index of the focused item in the context of all the - // items contained by this menu, whether focusable or not, used for figuring - // out focus correctly when the items have changed in terms of whether - // they're focusable or not - // @ts-expect-error [FEI-5019] - TS2564 - Property 'focusedOriginalIndex' has no initializer and is not definitely assigned in the constructor. - focusedOriginalIndex: number; - // Whether any items have been selected since the menu was opened - // @ts-expect-error [FEI-5019] - TS2564 - Property 'itemsClicked' has no initializer and is not definitely assigned in the constructor. - itemsClicked: boolean; popperElement: HTMLElement | null | undefined; + // Keeps a reference of the virtualized list instance - virtualizedListRef: { - current: null | React.ElementRef; - }; + virtualizedListRef: React.RefObject; handleKeyDownDebounced: (key: string) => void; @@ -440,6 +427,16 @@ class DropdownCore extends React.Component { this.removeEventListeners(); } + // Keeps track of the index of the focused item, out of a list of focusable items + focusedIndex = -1; + // Keeps track of the index of the focused item in the context of all the + // items contained by this menu, whether focusable or not, used for figuring + // out focus correctly when the items have changed in terms of whether + // they're focusable or not + focusedOriginalIndex = -1; + // Whether any items have been selected since the menu was opened + itemsClicked = false; + searchFieldRef: { current: null | HTMLInputElement; } = React.createRef(); @@ -532,32 +529,66 @@ class DropdownCore extends React.Component { focusCurrentItem(onFocus?: (node: HTMLElement) => void) { const focusedItemRef = this.state.itemRefs[this.focusedIndex]; - if (focusedItemRef) { - // force react-window to scroll to ensure the focused item is visible - if (this.virtualizedListRef.current) { - // Our focused index does not include disabled items, but the - // react-window index system does include the disabled items - // in the count. So we need to use "originalIndex", which - // does account for disabled items. - this.virtualizedListRef.current.scrollToItem( - focusedItemRef.originalIndex, - ); + if (!focusedItemRef) { + return; + } + + const {current: virtualizedList} = this.virtualizedListRef; + if (virtualizedList) { + // Our focused index does not include disabled items, but the + // react-window index system does include the disabled items + // in the count. So we need to use "originalIndex", which + // does account for disabled items. + virtualizedList.scrollToItem(focusedItemRef.originalIndex); + } + + const focusNode = () => { + // No point in doing work if we're not open. + if (!this.props.open) { + return; } + // We look the item up just to make sure we have the right + // information at the point this function runs. + const currentFocusedItemRef = + this.state.itemRefs[this.focusedIndex]; + const node = ReactDOM.findDOMNode( - focusedItemRef.ref.current, + currentFocusedItemRef.ref.current, ) as HTMLElement; + + if (!node && this.shouldVirtualizeList()) { + // Wait for the next animation frame to focus the item, + // that way the virtualized list has time to render the + // item in the DOM. We do this in a recursive way as + // occasionally, one frame is not enough. + this.props.schedule.animationFrame(focusNode); + return; + } + + // If the node doesn't exist and we're still mounted, then + // we need to schedule another focus attempt so that we run when + // the node *is* mounted. if (node) { node.focus(); // Keep track of the original index of the newly focused item. // To be used if the set of focusable items in the menu changes - this.focusedOriginalIndex = focusedItemRef.originalIndex; + this.focusedOriginalIndex = currentFocusedItemRef.originalIndex; if (onFocus) { // Call the callback with the node that was focused. onFocus(node); } } + }; + + // If we are virtualized, we need to make sure the scroll can occur + // before focus is updated. So, we schedule the focus to happen in an + // animation frame. + if (this.shouldVirtualizeList()) { + this.props.schedule.animationFrame(focusNode); + } else { + focusNode(); } } @@ -588,7 +619,7 @@ class DropdownCore extends React.Component { return this.focusSearchField(); } this.focusedIndex = this.state.itemRefs.length - 1; - } else { + } else if (!this.isSearchFieldFocused()) { this.focusedIndex -= 1; } @@ -605,7 +636,7 @@ class DropdownCore extends React.Component { return this.focusSearchField(); } this.focusedIndex = 0; - } else { + } else if (!this.isSearchFieldFocused()) { this.focusedIndex += 1; } @@ -893,11 +924,10 @@ class DropdownCore extends React.Component { return { ...item, role: populatedProps.role || itemRole, - ref: item.focusable - ? this.state.itemRefs[focusIndex] + ref: + item.focusable && this.state.itemRefs[focusIndex] ? this.state.itemRefs[focusIndex].ref - : null - : null, + : null, onClick: () => { this.handleItemClick(focusIndex, item); }, diff --git a/packages/wonder-blocks-form/package.json b/packages/wonder-blocks-form/package.json index a984d5d61..195d3ff46 100644 --- a/packages/wonder-blocks-form/package.json +++ b/packages/wonder-blocks-form/package.json @@ -25,7 +25,7 @@ }, "peerDependencies": { "aphrodite": "^1.2.5", - "react": "16.14.0" + "react": "18.2.0" }, "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1" diff --git a/packages/wonder-blocks-form/src/components/__tests__/labeled-text-field.test.tsx b/packages/wonder-blocks-form/src/components/__tests__/labeled-text-field.test.tsx index 8c86af68c..d91bdc708 100644 --- a/packages/wonder-blocks-form/src/components/__tests__/labeled-text-field.test.tsx +++ b/packages/wonder-blocks-form/src/components/__tests__/labeled-text-field.test.tsx @@ -333,7 +333,7 @@ describe("LabeledTextField", () => { // Act const field = await screen.findByRole("textbox"); - field.focus(); + await userEvent.click(field); // Assert expect(handleFocus).toHaveBeenCalled(); diff --git a/packages/wonder-blocks-form/src/hooks/__tests__/use-field-validation.test.ts b/packages/wonder-blocks-form/src/hooks/__tests__/use-field-validation.test.ts index a5c467e35..3a63d314b 100644 --- a/packages/wonder-blocks-form/src/hooks/__tests__/use-field-validation.test.ts +++ b/packages/wonder-blocks-form/src/hooks/__tests__/use-field-validation.test.ts @@ -1,11 +1,11 @@ -import {act, renderHook, RenderResult} from "@testing-library/react-hooks"; +import {act, renderHook, RenderHookResult} from "@testing-library/react"; import {useFieldValidation} from "../use-field-validation"; -type Result = RenderResult<{ - errorMessage: string | null; - onBlurValidation: (newValue: string) => void; - onChangeValidation: (newValue: string) => void; -}>; +type HookResult = RenderHookResult< + ReturnType, + Parameters[0] +>["result"]; + describe("useFieldValidation", () => { const testErrorMessage = "Error message"; @@ -181,14 +181,14 @@ describe("useFieldValidation", () => { [ "onChangeValidation", true, - (result: Result, value: string) => { + (result: HookResult, value: string) => { result.current.onChangeValidation(value); }, ], [ "onBlurValidation", false, - (result: Result, value: string) => { + (result: HookResult, value: string) => { result.current.onBlurValidation(value); }, ], @@ -197,7 +197,7 @@ describe("useFieldValidation", () => { ( _actionName: string, instantValidation: boolean, - action: (result: Result, value: string) => void, + action: (result: HookResult, value: string) => void, ) => { describe("validate", () => { it("should call the validate prop", () => { diff --git a/packages/wonder-blocks-grid/package.json b/packages/wonder-blocks-grid/package.json index 22bb6f021..3345c4279 100644 --- a/packages/wonder-blocks-grid/package.json +++ b/packages/wonder-blocks-grid/package.json @@ -22,7 +22,7 @@ }, "peerDependencies": { "aphrodite": "^1.2.5", - "react": "16.14.0" + "react": "18.2.0" }, "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1" diff --git a/packages/wonder-blocks-i18n/package.json b/packages/wonder-blocks-i18n/package.json index ef83f6dfc..c0f2076b0 100644 --- a/packages/wonder-blocks-i18n/package.json +++ b/packages/wonder-blocks-i18n/package.json @@ -18,7 +18,7 @@ "@babel/runtime": "^7.18.6" }, "peerDependencies": { - "react": "16.14.0" + "react": "18.2.0" }, "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1" diff --git a/packages/wonder-blocks-i18n/src/components/__tests__/i18n-inline-markup.test.tsx b/packages/wonder-blocks-i18n/src/components/__tests__/i18n-inline-markup.test.tsx index 07466a8db..19f0d7765 100644 --- a/packages/wonder-blocks-i18n/src/components/__tests__/i18n-inline-markup.test.tsx +++ b/packages/wonder-blocks-i18n/src/components/__tests__/i18n-inline-markup.test.tsx @@ -5,66 +5,56 @@ import * as ParseSimpleHTML from "../parse-simple-html"; import {I18nInlineMarkup} from "../i18n-inline-markup"; import * as i18n from "../../functions/i18n"; -const SingleShallowSubstitution = (): React.ReactElement => { - return ( - ( - - [Underline:{t}] - - )} - > - {i18n._( - "-6\u00b0C, Sunny, Fells like: -12, Wind: VR 5 km/h", - )} - - ); -}; - -const MultipleShallowSubstitution = (): React.ReactElement => { - return ( - ( - - __{t}__ - - )} - i={(t: string) => ( - - *{t}* - - )} - > - {i18n._( - "-6\u00b0C, Sunny, Fells like: -12, Wind: VR 5 km/h", - )} - - ); -}; - -const ElementWrapper = (): React.ReactElement => { - return ( - ( - {t} - )} - u={(t: string) => ( - - __{t}__ - - )} - i={(t: string) => ( - - *{t}* - - )} - > - {i18n._( - "-6\u00b0C, Sunny, Fells like: -12, Wind: VR 5 km/h", - )} - - ); -}; +const SingleShallowSubstitution = (): React.ReactElement => ( + ( + + [Underline:{t}] + + )} + > + {i18n._("-6\u00b0C, Sunny, Fells like: -12, Wind: VR 5 km/h")} + +); + +const MultipleShallowSubstitution = (): React.ReactElement => ( + ( + + __{t}__ + + )} + i={(t: string) => ( + + *{t}* + + )} + > + {i18n._( + "-6\u00b0C, Sunny, Fells like: -12, Wind: VR 5 km/h", + )} + +); + +const ElementWrapper = (): React.ReactElement => ( + {t}} + u={(t: string) => ( + + __{t}__ + + )} + i={(t: string) => ( + + *{t}* + + )} + > + {i18n._( + "-6\u00b0C, Sunny, Fells like: -12, Wind: VR 5 km/h", + )} + +); describe("I18nInlineMarkup", () => { test("SingleShallowSubstitution", () => { @@ -111,6 +101,7 @@ describe("I18nInlineMarkup", () => { }); it("should throw an error if `parseSimpleHTML()` throws", () => { + // Arrange jest.spyOn( ParseSimpleHTML, "parseSimpleHTML", @@ -118,14 +109,27 @@ describe("I18nInlineMarkup", () => { throw new Error("foo"); }); + // Act const action = () => render( // @ts-expect-error [FEI-5019] - TS2769 - No overload matches this call. {value}}> {"Hello world!"} , + + // NOTE(somewhatabstract): This component uses its own + // custom error handling instead of relying on error + // boundaries. This seems to break the React error boundary + // stuff, at least when testing, so the `boundary` test + // harness adapter never gets the thrown error. However, + // we can use this `legacyRoot` setting to use synchronous + // rendering like before, and then the test works as-is. + // We probably should rework this stuff before they drop + // this feature. + {legacyRoot: true}, ); + // Assert expect(action).toThrowErrorMatchingInlineSnapshot(`"foo"`); }); diff --git a/packages/wonder-blocks-icon-button/package.json b/packages/wonder-blocks-icon-button/package.json index ffcf27321..c83bdffa5 100644 --- a/packages/wonder-blocks-icon-button/package.json +++ b/packages/wonder-blocks-icon-button/package.json @@ -24,9 +24,9 @@ }, "peerDependencies": { "aphrodite": "^1.2.5", - "react": "16.14.0", - "react-router": "5.2.1", - "react-router-dom": "5.3.0" + "react": "18.2.0", + "react-router": "5.3.4", + "react-router-dom": "5.3.4" }, "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1" diff --git a/packages/wonder-blocks-icon/package.json b/packages/wonder-blocks-icon/package.json index a927a18a7..772bd70b6 100644 --- a/packages/wonder-blocks-icon/package.json +++ b/packages/wonder-blocks-icon/package.json @@ -25,6 +25,6 @@ "peerDependencies": { "@phosphor-icons/core": "^2.0.2", "aphrodite": "^1.2.5", - "react": "16.14.0" + "react": "18.2.0" } } \ No newline at end of file diff --git a/packages/wonder-blocks-labeled-field/package.json b/packages/wonder-blocks-labeled-field/package.json index f942121d3..7f8d0f914 100644 --- a/packages/wonder-blocks-labeled-field/package.json +++ b/packages/wonder-blocks-labeled-field/package.json @@ -24,7 +24,7 @@ }, "peerDependencies": { "aphrodite": "^1.2.5", - "react": "16.14.0" + "react": "18.2.0" }, "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1" diff --git a/packages/wonder-blocks-layout/package.json b/packages/wonder-blocks-layout/package.json index db338c9c1..d735d1dcf 100644 --- a/packages/wonder-blocks-layout/package.json +++ b/packages/wonder-blocks-layout/package.json @@ -22,7 +22,7 @@ }, "peerDependencies": { "aphrodite": "^1.2.5", - "react": "16.14.0" + "react": "18.2.0" }, "author": "", "license": "MIT" diff --git a/packages/wonder-blocks-link/package.json b/packages/wonder-blocks-link/package.json index beefd7883..4cb210ad2 100644 --- a/packages/wonder-blocks-link/package.json +++ b/packages/wonder-blocks-link/package.json @@ -23,9 +23,9 @@ }, "peerDependencies": { "aphrodite": "^1.2.5", - "react": "16.14.0", - "react-router": "5.2.1", - "react-router-dom": "5.3.0" + "react": "18.2.0", + "react-router": "5.3.4", + "react-router-dom": "5.3.4" }, "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1" diff --git a/packages/wonder-blocks-modal/package.json b/packages/wonder-blocks-modal/package.json index 28dd9f07d..64a54629c 100644 --- a/packages/wonder-blocks-modal/package.json +++ b/packages/wonder-blocks-modal/package.json @@ -28,8 +28,8 @@ "peerDependencies": { "@phosphor-icons/core": "^2.0.2", "aphrodite": "^1.2.5", - "react": "16.14.0", - "react-dom": "16.14.0" + "react": "18.2.0", + "react-dom": "18.2.0" }, "devDependencies": { "@khanacademy/wonder-blocks-breadcrumbs": "^2.2.8", diff --git a/packages/wonder-blocks-pill/package.json b/packages/wonder-blocks-pill/package.json index a48049d46..267de79d7 100644 --- a/packages/wonder-blocks-pill/package.json +++ b/packages/wonder-blocks-pill/package.json @@ -24,7 +24,7 @@ }, "peerDependencies": { "aphrodite": "^1.2.5", - "react": "16.14.0" + "react": "18.2.0" }, "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1" diff --git a/packages/wonder-blocks-popover/package.json b/packages/wonder-blocks-popover/package.json index 953936b51..6eee647cd 100644 --- a/packages/wonder-blocks-popover/package.json +++ b/packages/wonder-blocks-popover/package.json @@ -27,8 +27,8 @@ "@phosphor-icons/core": "^2.0.2", "@popperjs/core": "^2.10.1", "aphrodite": "^1.2.5", - "react": "16.14.0", - "react-dom": "16.14.0", + "react": "18.2.0", + "react-dom": "18.2.0", "react-popper": "^2.0.0" }, "devDependencies": { diff --git a/packages/wonder-blocks-popover/src/components/__tests__/popover-event-listener.test.tsx b/packages/wonder-blocks-popover/src/components/__tests__/popover-event-listener.test.tsx index 7c0ba19ae..15e260ed0 100644 --- a/packages/wonder-blocks-popover/src/components/__tests__/popover-event-listener.test.tsx +++ b/packages/wonder-blocks-popover/src/components/__tests__/popover-event-listener.test.tsx @@ -27,7 +27,7 @@ describe("PopoverKeypressListener", () => { const onCloseMock = jest.fn(); const contentRef: React.RefObject = React.createRef(); - render( + const wrapper = render( { ); // Act - const event = new MouseEvent("click", {view: window, bubbles: true}); - const node = document.body; - if (node) { - // First click is ignored by PopoverEventListener - // because it is triggered when opening the popover. - node.dispatchEvent(event); - node.dispatchEvent(event); - } else { - // Signal that body was never found - expect(node).not.toBe(null); - } + // First click is ignored by PopoverEventListener + // because it is triggered when opening the popover. + await userEvent.click(wrapper.container); + await userEvent.click(wrapper.container); // Assert expect(onCloseMock).toHaveBeenCalled(); diff --git a/packages/wonder-blocks-popover/src/components/__tests__/popover.test.tsx b/packages/wonder-blocks-popover/src/components/__tests__/popover.test.tsx index 0098ccb3b..518870426 100644 --- a/packages/wonder-blocks-popover/src/components/__tests__/popover.test.tsx +++ b/packages/wonder-blocks-popover/src/components/__tests__/popover.test.tsx @@ -248,27 +248,21 @@ describe("Popover", () => { // Act // Close the popover by pressing Enter on the close button. - // NOTE: we need to use fireEvent here because await userEvent doesn't support - // keyUp/Down events and we use these handlers to override the default - // behavior of the button. - // eslint-disable-next-line testing-library/prefer-user-event - fireEvent.keyDown( - await screen.findByRole("button", {name: "Click to close popover"}), - {key: "Enter", code: "Enter", charCode: 13}, - ); - // eslint-disable-next-line testing-library/prefer-user-event - fireEvent.keyDown( - await screen.findByRole("button", {name: "Click to close popover"}), - {key: "Enter", code: "Enter", charCode: 13}, - ); - // eslint-disable-next-line testing-library/prefer-user-event - fireEvent.keyUp( - await screen.findByRole("button", {name: "Click to close popover"}), - {key: "Enter", code: "Enter", charCode: 13}, - ); + const button = await screen.findByRole("button", { + name: "Click to close popover", + }); + /* eslint-disable testing-library/prefer-user-event */ + // NOTE: we need to use fireEvent here because await userEvent doesn't + // support keyUp/Down events and we use these handlers to override the + // default behavior of the button. + fireEvent.keyDown(button, {key: "Enter", code: "Enter", charCode: 13}); + fireEvent.keyUp(button, {key: "Enter", code: "Enter", charCode: 13}); + /* eslint-enable testing-library/prefer-user-event */ // Assert - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), + ); }); describe("return focus", () => { @@ -301,7 +295,7 @@ describe("Popover", () => { const closeButton = await screen.findByRole("button", { name: "Close Popover", }); - closeButton.click(); + await userEvent.click(closeButton, {pointerEventsCheck: 0}); // Assert expect(anchorButton).toHaveFocus(); diff --git a/packages/wonder-blocks-popover/src/components/popover-anchor.ts b/packages/wonder-blocks-popover/src/components/popover-anchor.ts index 5e53b8d13..8768c5fbb 100644 --- a/packages/wonder-blocks-popover/src/components/popover-anchor.ts +++ b/packages/wonder-blocks-popover/src/components/popover-anchor.ts @@ -70,19 +70,15 @@ export default class PopoverAnchor extends React.Component { } else { // add onClick handler to automatically open the dialog after // clicking on this anchor element - // @ts-expect-error [FEI-5019] - TS2769 - No overload matches this call. return React.cloneElement(children, { - // @ts-expect-error [FEI-5019] - TS2339 - Property 'props' does not exist on type 'ReactElement> | (ReactElement> & string) | ... 9 more ... | (((arg1: { ...; }) => ReactElement<...>) & true)'. ...children.props, ...sharedProps, - // @ts-expect-error [FEI-5019] - TS2339 - Property 'props' does not exist on type 'ReactElement> | (ReactElement> & string) | ... 9 more ... | (((arg1: { ...; }) => ReactElement<...>) & true)'. onClick: children.props.onClick ? // @ts-expect-error [FEI-5019] - TS7006 - Parameter 'e' implicitly has an 'any' type. (e) => { e.stopPropagation(); // This is done to avoid overriding a custom onClick // handler inside the children node - // @ts-expect-error [FEI-5019] - TS2339 - Property 'props' does not exist on type 'ReactElement> | (ReactElement> & string) | ... 9 more ... | (((arg1: { ...; }) => ReactElement<...>) & true)'. children.props.onClick(); onClick(); } diff --git a/packages/wonder-blocks-progress-spinner/package.json b/packages/wonder-blocks-progress-spinner/package.json index 2ea05cb79..f555ba5f6 100644 --- a/packages/wonder-blocks-progress-spinner/package.json +++ b/packages/wonder-blocks-progress-spinner/package.json @@ -21,7 +21,7 @@ }, "peerDependencies": { "aphrodite": "^1.2.5", - "react": "16.14.0" + "react": "18.2.0" }, "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1" diff --git a/packages/wonder-blocks-search-field/package.json b/packages/wonder-blocks-search-field/package.json index c1478c35f..6c565fcf4 100644 --- a/packages/wonder-blocks-search-field/package.json +++ b/packages/wonder-blocks-search-field/package.json @@ -26,7 +26,7 @@ "peerDependencies": { "@phosphor-icons/core": "^2.0.2", "aphrodite": "^1.2.5", - "react": "16.14.0" + "react": "18.2.0" }, "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1" diff --git a/packages/wonder-blocks-switch/package.json b/packages/wonder-blocks-switch/package.json index eb42fa1a9..19e7980b5 100644 --- a/packages/wonder-blocks-switch/package.json +++ b/packages/wonder-blocks-switch/package.json @@ -23,7 +23,7 @@ }, "peerDependencies": { "aphrodite": "^1.2.5", - "react": "16.14.0" + "react": "18.2.0" }, "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1" diff --git a/packages/wonder-blocks-testing-core/package.json b/packages/wonder-blocks-testing-core/package.json index f81b7bc21..305dd3003 100644 --- a/packages/wonder-blocks-testing-core/package.json +++ b/packages/wonder-blocks-testing-core/package.json @@ -17,12 +17,12 @@ }, "peerDependencies": { "@khanacademy/wonder-stuff-core": "^1.2.2", - "@storybook/addon-actions": "^7.0.0", + "@storybook/addon-actions": "^8.2.1", "aphrodite": "^1.2.5", "node-fetch": "^2.6.7", - "react": "16.14.0", - "react-dom": "16.14.0", - "react-router-dom": "5.3.0" + "react": "18.2.0", + "react-dom": "18.2.0", + "react-router-dom": "5.3.4" }, "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1", diff --git a/packages/wonder-blocks-testing-core/src/harness/adapters/__tests__/error-boundary.test.tsx b/packages/wonder-blocks-testing-core/src/harness/adapters/__tests__/error-boundary.test.tsx index c790a9dac..0b9646008 100644 --- a/packages/wonder-blocks-testing-core/src/harness/adapters/__tests__/error-boundary.test.tsx +++ b/packages/wonder-blocks-testing-core/src/harness/adapters/__tests__/error-boundary.test.tsx @@ -1,7 +1,6 @@ import * as React from "react"; import {it, describe, expect} from "@jest/globals"; -import {render} from "@testing-library/react"; -import {renderHook} from "@testing-library/react-hooks"; +import {render, renderHook} from "@testing-library/react"; import * as ErrorBoundary from "../error-boundary"; diff --git a/packages/wonder-blocks-testing-core/src/harness/make-test-harness.tsx b/packages/wonder-blocks-testing-core/src/harness/make-test-harness.tsx index 179d7c4f7..a877973f3 100644 --- a/packages/wonder-blocks-testing-core/src/harness/make-test-harness.tsx +++ b/packages/wonder-blocks-testing-core/src/harness/make-test-harness.tsx @@ -43,7 +43,7 @@ export const makeTestHarness = ( ...defaultConfigs, ...configs, }; - const harnessedComponent = React.forwardRef((props: TProps, ref) => ( + const harnessedComponent = React.forwardRef((props, ref) => ( @@ -55,6 +55,8 @@ export const makeTestHarness = ( Component.displayName || Component.name || "Component" })`; - return harnessedComponent; + return harnessedComponent as React.ForwardRefExoticComponent< + React.PropsWithoutRef & React.RefAttributes + >; }; }; diff --git a/packages/wonder-blocks-testing/package.json b/packages/wonder-blocks-testing/package.json index e45a47b75..79fcfddb1 100644 --- a/packages/wonder-blocks-testing/package.json +++ b/packages/wonder-blocks-testing/package.json @@ -20,12 +20,12 @@ }, "peerDependencies": { "@khanacademy/wonder-stuff-core": "^1.2.2", - "@storybook/addon-actions": "^7.0.0", + "@storybook/addon-actions": "^8.2.1", "aphrodite": "^1.2.5", "node-fetch": "^2.6.7", - "react": "16.14.0", - "react-dom": "16.14.0", - "react-router-dom": "5.3.0" + "react": "18.2.0", + "react-dom": "18.2.0", + "react-router-dom": "5.3.4" }, "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1", diff --git a/packages/wonder-blocks-testing/src/harness/adapters/__tests__/data.test.tsx b/packages/wonder-blocks-testing/src/harness/adapters/__tests__/data.test.tsx index 64e9d5445..e858f6a27 100644 --- a/packages/wonder-blocks-testing/src/harness/adapters/__tests__/data.test.tsx +++ b/packages/wonder-blocks-testing/src/harness/adapters/__tests__/data.test.tsx @@ -24,7 +24,9 @@ describe("WonderBlocksData.adapter", () => { return (
CONTENT:{" "} - {result.status === "success" ? result.data : undefined} + {result.status === "success" + ? String(result.data) + : undefined}
); }; @@ -40,7 +42,7 @@ describe("WonderBlocksData.adapter", () => { await waitFor(() => expect(container).toContainHTML("INTERCEPTED!")); }); - it("should render like we expect", () => { + it("should render like we expect", async () => { // Snapshot test is handy to visualize what's going on and help debug // test failures of the other cases. The other cases assert specifics. // Arrange @@ -50,7 +52,9 @@ describe("WonderBlocksData.adapter", () => { return (
CONTENT: - {result.status === "success" ? result.data : undefined} + {result.status === "success" + ? String(result.data) + : undefined}
); }; @@ -63,6 +67,7 @@ describe("WonderBlocksData.adapter", () => { ); // Assert + await screen.findByText("CONTENT:INTERCEPTED!"); expect(container).toMatchInlineSnapshot(`
diff --git a/packages/wonder-blocks-theming/package.json b/packages/wonder-blocks-theming/package.json index e1d2d6cf5..18ca37004 100644 --- a/packages/wonder-blocks-theming/package.json +++ b/packages/wonder-blocks-theming/package.json @@ -14,8 +14,8 @@ "dependencies": {}, "peerDependencies": { "aphrodite": "^1.2.5", - "react": "16.14.0", - "react-dom": "16.14.0" + "react": "18.2.0", + "react-dom": "18.2.0" }, "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1" diff --git a/packages/wonder-blocks-theming/src/hooks/__tests__/use-scoped-theme.test.tsx b/packages/wonder-blocks-theming/src/hooks/__tests__/use-scoped-theme.test.tsx index ce819eaeb..d738e4fe3 100644 --- a/packages/wonder-blocks-theming/src/hooks/__tests__/use-scoped-theme.test.tsx +++ b/packages/wonder-blocks-theming/src/hooks/__tests__/use-scoped-theme.test.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import {renderHook} from "@testing-library/react-hooks"; +import {renderHook} from "@testing-library/react"; import { createThemeContext, diff --git a/packages/wonder-blocks-theming/src/hooks/__tests__/use-styles.test.ts b/packages/wonder-blocks-theming/src/hooks/__tests__/use-styles.test.ts index 05081c6bc..8efe3f7e0 100644 --- a/packages/wonder-blocks-theming/src/hooks/__tests__/use-styles.test.ts +++ b/packages/wonder-blocks-theming/src/hooks/__tests__/use-styles.test.ts @@ -1,4 +1,4 @@ -import {renderHook} from "@testing-library/react-hooks"; +import {renderHook} from "@testing-library/react"; import {StyleSheet} from "aphrodite"; import {ThemedStylesFn} from "../../types"; diff --git a/packages/wonder-blocks-timing/package.json b/packages/wonder-blocks-timing/package.json index 2c7977bd8..94b1378d2 100644 --- a/packages/wonder-blocks-timing/package.json +++ b/packages/wonder-blocks-timing/package.json @@ -14,10 +14,11 @@ "test": "echo \"Error: no test specified\" && exit 1" }, "peerDependencies": { - "react": "16.14.0" + "react": "18.2.0" }, "devDependencies": { - "@khanacademy/wb-dev-build-settings": "^1.0.1" + "@khanacademy/wb-dev-build-settings": "^1.0.1", + "@khanacademy/wonder-blocks-testing-core": "^1.1.0" }, "author": "", "license": "MIT" diff --git a/packages/wonder-blocks-timing/src/hooks/__tests__/use-interval.test.ts b/packages/wonder-blocks-timing/src/hooks/__tests__/use-interval.test.ts index e399bfc8e..036976cc2 100644 --- a/packages/wonder-blocks-timing/src/hooks/__tests__/use-interval.test.ts +++ b/packages/wonder-blocks-timing/src/hooks/__tests__/use-interval.test.ts @@ -1,4 +1,5 @@ -import {renderHook, act} from "@testing-library/react-hooks"; +import {renderHook, act} from "@testing-library/react"; +import {hookHarness} from "@khanacademy/wonder-blocks-testing-core"; import {SchedulePolicy, ClearPolicy, ActionPolicy} from "../../util/policies"; import {useInterval} from "../use-interval"; @@ -14,22 +15,30 @@ describe("useInterval", () => { it("throws if the action is not a function", () => { // Arrange + const captureErrorFn = jest.fn(); // Act - const {result} = renderHook(() => useInterval(null as any, 1000)); + renderHook(() => useInterval(null as any, 1000), { + wrapper: hookHarness({boundary: captureErrorFn}), + }); + const result = captureErrorFn.mock.calls[0][0]; // Assert - expect(result.error).toEqual(Error("Action must be a function")); + expect(result).toEqual(Error("Action must be a function")); }); it("throws if the period is less than 1", () => { // Arrange + const captureErrorFn = jest.fn(); // Act - const {result} = renderHook(() => useInterval(() => {}, 0)); + renderHook(() => useInterval(() => {}, 0), { + wrapper: hookHarness({boundary: captureErrorFn}), + }); + const result = captureErrorFn.mock.calls[0][0]; // Assert - expect(result.error).toEqual(Error("Interval period must be >= 1")); + expect(result).toEqual(Error("Interval period must be >= 1")); }); it("sets an interval when schedule policy is SchedulePolicy.Immediately", () => { diff --git a/packages/wonder-blocks-timing/src/hooks/__tests__/use-timeout.test.ts b/packages/wonder-blocks-timing/src/hooks/__tests__/use-timeout.test.ts index 7de73fa08..ce2644829 100644 --- a/packages/wonder-blocks-timing/src/hooks/__tests__/use-timeout.test.ts +++ b/packages/wonder-blocks-timing/src/hooks/__tests__/use-timeout.test.ts @@ -1,4 +1,5 @@ -import {renderHook, act} from "@testing-library/react-hooks"; +import {renderHook, act} from "@testing-library/react"; +import {hookHarness} from "@khanacademy/wonder-blocks-testing-core"; import {SchedulePolicy, ClearPolicy, ActionPolicy} from "../../util/policies"; import {useTimeout} from "../use-timeout"; @@ -14,22 +15,30 @@ describe("useTimeout", () => { it("throws if the action is not a function", () => { // Arrange + const captureErrorFn = jest.fn(); // Act - const {result} = renderHook(() => useTimeout(null as any, 1000)); + renderHook(() => useTimeout(null as any, 1000), { + wrapper: hookHarness({boundary: captureErrorFn}), + }); + const result = captureErrorFn.mock.calls[0][0]; // Assert - expect(result.error).toEqual(Error("Action must be a function")); + expect(result).toEqual(Error("Action must be a function")); }); it("throws if the period is less than 0", () => { // Arrange + const captureErrorFn = jest.fn(); // Act - const {result} = renderHook(() => useTimeout(() => {}, -1)); + renderHook(() => useTimeout(() => {}, -1), { + wrapper: hookHarness({boundary: captureErrorFn}), + }); + const result = captureErrorFn.mock.calls[0][0]; // Assert - expect(result.error).toEqual(Error("Timeout period must be >= 0")); + expect(result).toEqual(Error("Timeout period must be >= 0")); }); it("should return an ITimeout", () => { diff --git a/packages/wonder-blocks-timing/tsconfig-build.json b/packages/wonder-blocks-timing/tsconfig-build.json index f24528d77..669f5d5f0 100644 --- a/packages/wonder-blocks-timing/tsconfig-build.json +++ b/packages/wonder-blocks-timing/tsconfig-build.json @@ -5,5 +5,7 @@ "outDir": "./dist", "rootDir": "src" }, - "references": [] + "references": [ + {"path": "../wonder-blocks-testing-core/tsconfig-build.json"}, + ] } \ No newline at end of file diff --git a/packages/wonder-blocks-toolbar/package.json b/packages/wonder-blocks-toolbar/package.json index 9416eb39f..59040c67e 100644 --- a/packages/wonder-blocks-toolbar/package.json +++ b/packages/wonder-blocks-toolbar/package.json @@ -22,7 +22,7 @@ }, "peerDependencies": { "aphrodite": "^1.2.5", - "react": "16.14.0" + "react": "18.2.0" }, "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1" diff --git a/packages/wonder-blocks-tooltip/package.json b/packages/wonder-blocks-tooltip/package.json index 3b1aaa452..cbc427c78 100644 --- a/packages/wonder-blocks-tooltip/package.json +++ b/packages/wonder-blocks-tooltip/package.json @@ -25,8 +25,8 @@ "peerDependencies": { "@popperjs/core": "^2.10.1", "aphrodite": "^1.2.5", - "react": "16.14.0", - "react-dom": "16.14.0", + "react": "18.2.0", + "react-dom": "18.2.0", "react-popper": "^2.0.0" }, "devDependencies": { diff --git a/packages/wonder-blocks-tooltip/src/components/__tests__/tooltip-anchor.test.tsx b/packages/wonder-blocks-tooltip/src/components/__tests__/tooltip-anchor.test.tsx index 6780ebefa..3d752700f 100644 --- a/packages/wonder-blocks-tooltip/src/components/__tests__/tooltip-anchor.test.tsx +++ b/packages/wonder-blocks-tooltip/src/components/__tests__/tooltip-anchor.test.tsx @@ -14,16 +14,8 @@ jest.mock("../../util/active-tracker"); describe("TooltipAnchor", () => { beforeEach(async () => { - // @ts-expect-error [FEI-5019] - TS2339 - Property 'mockReset' does not exist on type '{ (type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions | undefined): void; (type: string, listener: EventListenerOrEventListenerObject, options?: boolean | ... 1 more ... | undefined): void; }'. - if (typeof document.addEventListener.mockReset === "function") { - // @ts-expect-error [FEI-5019] - TS2339 - Property 'mockRestore' does not exist on type '{ (type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions | undefined): void; (type: string, listener: EventListenerOrEventListenerObject, options?: boolean | ... 1 more ... | undefined): void; }'. - document.addEventListener.mockRestore(); - } - // @ts-expect-error [FEI-5019] - TS2339 - Property 'mockReset' does not exist on type '{ (type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions | undefined): void; (type: string, listener: EventListenerOrEventListenerObject, options?: boolean | ... 1 more ... | undefined): void; }'. - if (typeof document.removeEventListener.mockReset === "function") { - // @ts-expect-error [FEI-5019] - TS2339 - Property 'mockRestore' does not exist on type '{ (type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions | undefined): void; (type: string, listener: EventListenerOrEventListenerObject, options?: boolean | ... 1 more ... | undefined): void; }'. - document.removeEventListener.mockRestore(); - } + jest.spyOn(document, "addEventListener").mockRestore(); + jest.spyOn(document, "removeEventListener").mockRestore(); jest.clearAllTimers(); jest.useFakeTimers(); @@ -322,7 +314,7 @@ describe("TooltipAnchor", () => { test("active state was not stolen, active is set to false with delay", async () => { // Arrange const ue = userEvent.setup({ - advanceTimers: jest.advanceTimersByTime, + advanceTimers: jest.advanceTimersByTimeAsync, }); const timeoutSpy = jest.spyOn(global, "setTimeout"); let activeState = false; @@ -363,7 +355,7 @@ describe("TooltipAnchor", () => { test("active state was not stolen, gives up active state", async () => { // Arrange const ue = userEvent.setup({ - advanceTimers: jest.advanceTimersByTime, + advanceTimers: jest.advanceTimersByTimeAsync, }); const timeoutSpy = jest.spyOn(global, "setTimeout"); const {default: ActiveTracker} = await import( @@ -409,7 +401,7 @@ describe("TooltipAnchor", () => { test("active state was stolen, active is set to false immediately", async () => { // Arrange const ue = userEvent.setup({ - advanceTimers: jest.advanceTimersByTime, + advanceTimers: jest.advanceTimersByTimeAsync, }); const timeoutSpy = jest.spyOn(global, "setTimeout"); let activeState = false; @@ -606,7 +598,7 @@ describe("TooltipAnchor", () => { test("active state was not stolen, active is set to false with delay", async () => { // Arrange const ue = userEvent.setup({ - advanceTimers: jest.advanceTimersByTime, + advanceTimers: jest.advanceTimersByTimeAsync, }); const timeoutSpy = jest.spyOn(global, "setTimeout"); let activeState = false; @@ -646,7 +638,7 @@ describe("TooltipAnchor", () => { test("active state was not stolen, gives up active state", async () => { // Arrange const ue = userEvent.setup({ - advanceTimers: jest.advanceTimersByTime, + advanceTimers: jest.advanceTimersByTimeAsync, }); const timeoutSpy = jest.spyOn(global, "setTimeout"); const {default: ActiveTracker} = await import( @@ -691,7 +683,7 @@ describe("TooltipAnchor", () => { test("active state was stolen, active is set to false immediately", async () => { // Arrange const ue = userEvent.setup({ - advanceTimers: jest.advanceTimersByTime, + advanceTimers: jest.advanceTimersByTimeAsync, }); const timeoutSpy = jest.spyOn(global, "setTimeout"); let activeState = false; diff --git a/packages/wonder-blocks-tooltip/src/components/__tests__/tooltip.integration.test.tsx b/packages/wonder-blocks-tooltip/src/components/__tests__/tooltip.integration.test.tsx index f18885b74..ad85a84a2 100644 --- a/packages/wonder-blocks-tooltip/src/components/__tests__/tooltip.integration.test.tsx +++ b/packages/wonder-blocks-tooltip/src/components/__tests__/tooltip.integration.test.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import {render, screen, fireEvent} from "@testing-library/react"; +import {render, screen, fireEvent, waitFor} from "@testing-library/react"; import {userEvent} from "@testing-library/user-event"; import Tooltip from "../tooltip"; @@ -55,7 +55,7 @@ describe("tooltip integration tests", () => { it("should close TooltipBubble on mouseleave on TooltipBubble", async () => { // Arrange const ue = userEvent.setup({ - advanceTimers: jest.advanceTimersByTime, + advanceTimers: jest.advanceTimersByTimeAsync, }); render(an anchor); @@ -82,7 +82,9 @@ describe("tooltip integration tests", () => { jest.runAllTimers(); // Assert - expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + await waitFor(() => + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(), + ); }); it("should have an opened tooltip when subsequent mouseenter, mouseleave, and mouseenter events occur", async () => { @@ -92,7 +94,7 @@ describe("tooltip integration tests", () => { // Arrange const ue = userEvent.setup({ - advanceTimers: jest.advanceTimersByTime, + advanceTimers: jest.advanceTimersByTimeAsync, }); render(an anchor); @@ -104,7 +106,7 @@ describe("tooltip integration tests", () => { await jest.runAllTimers(); // We add `hidden: true` because the tooltip is initially hidden while // it is re-positioned - expect(screen.getByRole("tooltip", {hidden: true})).toBeInTheDocument(); + await screen.findByRole("tooltip", {hidden: true}); // Trigger mouseleave and mouseenter event and run timers only after // both have been triggered. This simulates the mouseenter event being // triggered before the tooltip is closed from the mouseleave event diff --git a/packages/wonder-blocks-typography/package.json b/packages/wonder-blocks-typography/package.json index 556fe99a6..a93ca6ba7 100644 --- a/packages/wonder-blocks-typography/package.json +++ b/packages/wonder-blocks-typography/package.json @@ -20,7 +20,7 @@ }, "peerDependencies": { "aphrodite": "^1.2.5", - "react": "16.14.0" + "react": "18.2.0" }, "devDependencies": { "@khanacademy/wb-dev-build-settings": "^1.0.1" diff --git a/tsconfig.json b/tsconfig.json index 15616e625..4bf295422 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,6 @@ "paths": { "@khanacademy/*": ["./packages/*/src"] - }, + } } } diff --git a/types/jest-extended.d.ts b/types/jest-extended.d.ts new file mode 100644 index 000000000..06bbf1a61 --- /dev/null +++ b/types/jest-extended.d.ts @@ -0,0 +1,930 @@ +/* eslint-disable @typescript-eslint/no-empty-interface */ +/* eslint-disable jsdoc/valid-types */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** +We use matchers provided by third-party packages that extend the `Matchers` +interface within the `jest` namespace. TypeScript won't pick these up unless +they are imported in a TypeScript file. This can be done by: + +1. Installing `ts-node` and making the Jest config a TS file +(see https://www.npmjs.com/package/@testing-library/jest-dom#with-typescript +for example) then import from there and include the jest setup file in the +TypeScript includes. +2. Vendoring the type doc files like we're doing here. + +This is a copy of the type definition file from jest-extended, amended to make +sure the matchers extend the jest-expect interface. + */ + +interface CustomMatchers extends Record { + /** + * Note: Currently unimplemented + * Passing assertion + * + * @param {String} message + */ + pass(message: string): R; + + /** + * Note: Currently unimplemented + * Failing assertion + * + * @param {String} message + */ + fail(message: string): R; + + /** + * Use .toBeEmpty when checking if a String '', Array [] or Object {} is empty. + */ + toBeEmpty(): R; + + /** + * Use .toBeOneOf when checking if a value is a member of a given Array. + * @param {Array.<*>} members + */ + toBeOneOf(members: readonly E[]): R; + + /** + * Use `.toBeNil` when checking a value is `null` or `undefined`. + */ + toBeNil(): R; + + /** + * Use `.toSatisfy` when you want to use a custom matcher by supplying a predicate function that returns a `Boolean`. + * @param {Function} predicate + */ + toSatisfy(predicate: (x: E) => boolean): R; + + /** + * Use `.toBeArray` when checking if a value is an `Array`. + */ + toBeArray(): R; + + /** + * Use `.toBeArrayOfSize` when checking if a value is an `Array` of size x. + * @param {Number} x + */ + toBeArrayOfSize(x: number): R; + + /** + * Use `.toBeAfter` when checking if a date occurs after `date`. + * @param {Date} date + */ + toBeAfter(date: Date): R; + + /** + * Use `.toBeBefore` when checking if a date occurs before `date`. + * @param {Date} date + */ + toBeBefore(date: Date): R; + + /** + * Use `.toIncludeAllMembers` when checking if an `Array` contains all of the same members of a given set. + * @param {Array.<*>} members + */ + toIncludeAllMembers(members: readonly E[]): R; + + /** + * Use `.toIncludeAnyMembers` when checking if an `Array` contains any of the members of a given set. + * @param {Array.<*>} members + */ + toIncludeAnyMembers(members: readonly E[]): R; + + /** + * Use `.toIncludeSameMembers` when checking if two arrays contain equal values, in any order. + * @param {Array.<*>} members + */ + toIncludeSameMembers(members: readonly E[]): R; + + /** + * Use `.toPartiallyContain` when checking if any array value matches the partial member. + * @param {*} member + */ + toPartiallyContain(member: E): R; + + /** + * Use `.toSatisfyAll` when you want to use a custom matcher by supplying a predicate function that returns a `Boolean` for all values in an array. + * @param {Function} predicate + */ + toSatisfyAll(predicate: (x: E) => boolean): R; + + /** + * Use `.toSatisfyAny` when you want to use a custom matcher by supplying a predicate function that returns `true` for any matching value in an array. + * @param {Function} predicate + */ + toSatisfyAny(predicate: (x: any) => boolean): R; + + /** + * Use `.toBeBoolean` when checking if a value is a `Boolean`. + */ + toBeBoolean(): R; + + /** + * Use `.toBeTrue` when checking a value is equal (===) to `true`. + */ + toBeTrue(): R; + + /** + * Use `.toBeFalse` when checking a value is equal (===) to `false`. + */ + toBeFalse(): R; + + /** + * Use `.toBeDate` when checking if a value is a `Date`. + */ + toBeDate(): R; + + /** + * Use `.toBeValidDate` when checking if a value is a `valid Date`. + */ + toBeValidDate(): R; + + /** + * Use `.toBeFunction` when checking if a value is a `Function`. + */ + toBeFunction(): R; + + /** + * Use `.toBeDateString` when checking if a value is a valid date string. + */ + toBeDateString(): R; + + /** + * Use `.toBeHexadecimal` when checking if a value is a valid HTML hex color. + */ + toBeHexadecimal(): R; + + /** + * Use `.toHaveBeenCalledBefore` when checking if a `Mock` was called before another `Mock`. + * + * Note: Required Jest version >=23 + * + * @param {Mock} mock + * @param {boolean} [failIfNoSecondInvocation=true] + */ + toHaveBeenCalledBefore( + mock: jest.MockInstance, + failIfNoSecondInvocation?: boolean, + ): R; + + /** + * Use `.toHaveBeenCalledAfter` when checking if a `Mock` was called after another `Mock`. + * + * Note: Required Jest version >=23 + * + * @param {Mock} mock + * @param {boolean} [failIfNoFirstInvocation=true] + */ + toHaveBeenCalledAfter( + mock: jest.MockInstance, + failIfNoFirstInvocation?: boolean, + ): R; + + /** + * Use `.toHaveBeenCalledOnce` to check if a `Mock` was called exactly one time. + */ + toHaveBeenCalledOnce(): R; + + /** + * Use `.toHaveBeenCalledExactlyOnceWith` to check if a `Mock` was called exactly one time with the expected value. + */ + toHaveBeenCalledExactlyOnceWith(...args: unknown[]): R; + + /** + * Use `.toBeNumber` when checking if a value is a `Number`. + */ + toBeNumber(): R; + + /** + * Use `.toBeNaN` when checking a value is `NaN`. + */ + toBeNaN(): R; + + /** + * Use `.toBeFinite` when checking if a value is a `Number`, not `NaN` or `Infinity`. + */ + toBeFinite(): R; + + /** + * Use `.toBePositive` when checking if a value is a positive `Number`. + */ + toBePositive(): R; + + /** + * Use `.toBeNegative` when checking if a value is a negative `Number`. + */ + toBeNegative(): R; + + /** + * Use `.toBeEven` when checking if a value is an even `Number`. + */ + toBeEven(): R; + + /** + * Use `.toBeOdd` when checking if a value is an odd `Number`. + */ + toBeOdd(): R; + + /** + * Use `.toBeWithin` when checking if a number is in between the given bounds of: start (inclusive) and end (exclusive). + * + * @param {Number} start + * @param {Number} end + */ + toBeWithin(start: number, end: number): R; + + /** + * Use `.toBeInRange` when checking if an array has elements in range min (inclusive) and max (inclusive). + * + * @param min + * @param max + */ + toBeInRange(min: number, max: number): R; + + /** + * Use `.toBeObject` when checking if a value is an `Object`. + */ + toBeObject(): R; + + /** + * Use `.toContainKey` when checking if an object contains the provided key. + * + * @param {String} key + */ + toContainKey(key: string): R; + + /** + * Use `.toContainKeys` when checking if an object has all of the provided keys. + * + * @param {Array.} keys + */ + toContainKeys(keys: readonly (keyof E | string)[]): R; + + /** + * Use `.toContainAllKeys` when checking if an object only contains all of the provided keys. + * + * @param {Array.} keys + */ + toContainAllKeys(keys: readonly (keyof E | string)[]): R; + + /** + * Use `.toContainAnyKeys` when checking if an object contains at least one of the provided keys. + * + * @param {Array.} keys + */ + toContainAnyKeys(keys: readonly (keyof E | string)[]): R; + + /** + * Use `.toContainValue` when checking if an object contains the provided value. + * + * @param {*} value + */ + toContainValue(value: E): R; + + /** + * Use `.toContainValues` when checking if an object contains all of the provided values. + * + * @param {Array.<*>} values + */ + toContainValues(values: readonly E[]): R; + + /** + * Use `.toContainAllValues` when checking if an object only contains all of the provided values. + * + * @param {Array.<*>} values + */ + toContainAllValues(values: readonly E[]): R; + + /** + * Use `.toContainAnyValues` when checking if an object contains at least one of the provided values. + * + * @param {Array.<*>} values + */ + toContainAnyValues(values: readonly E[]): R; + + /** + * Use `.toContainEntry` when checking if an object contains the provided entry. + * + * @param {Array.<[keyof E, E[keyof E]>} entry + */ + toContainEntry(entry: readonly [keyof E, E[keyof E]]): R; + + /** + * Use `.toContainEntries` when checking if an object contains all of the provided entries. + * + * @param {Array.>} entries + */ + toContainEntries( + entries: readonly (readonly [keyof E, E[keyof E]])[], + ): R; + + /** + * Use `.toContainAllEntries` when checking if an object only contains all of the provided entries. + * + * @param {Array.>} entries + */ + toContainAllEntries( + entries: readonly (readonly [keyof E, E[keyof E]])[], + ): R; + + /** + * Use `.toContainAnyEntries` when checking if an object contains at least one of the provided entries. + * + * @param {Array.>} entries + */ + toContainAnyEntries( + entries: readonly (readonly [keyof E, E[keyof E]])[], + ): R; + + /** + * Use `.toBeExtensible` when checking if an object is extensible. + */ + toBeExtensible(): R; + + /** + * Use `.toBeFrozen` when checking if an object is frozen. + */ + toBeFrozen(): R; + + /** + * Use `.toBeSealed` when checking if an object is sealed. + */ + toBeSealed(): R; + + /** + * Use `.toResolve` when checking if a promise resolves. + */ + toResolve(): R; + + /** + * Use `.toReject` when checking if a promise rejects. + */ + toReject(): R; + + /** + * Use `.toBeString` when checking if a value is a `String`. + */ + toBeString(): R; + + /** + * Use `.toEqualCaseInsensitive` when checking if a string is equal (===) to another ignoring the casing of both strings. + * + * @param {String} string + */ + toEqualCaseInsensitive(string: string): R; + + /** + * Use `.toStartWith` when checking if a `String` starts with a given `String` prefix. + * + * @param {String} prefix + */ + toStartWith(prefix: string): R; + + /** + * Use `.toEndWith` when checking if a `String` ends with a given `String` suffix. + * + * @param {String} suffix + */ + toEndWith(suffix: string): R; + + /** + * Use `.toInclude` when checking if a `String` includes the given `String` substring. + * + * @param {String} substring + */ + toInclude(substring: string): R; + + /** + * Use `.toIncludeRepeated` when checking if a `String` includes the given `String` substring the correct number of times. + * + * @param {String} substring + * @param {Number} times + */ + toIncludeRepeated(substring: string, times: number): R; + + /** + * Use `.toIncludeMultiple` when checking if a `String` includes all of the given substrings. + * + * @param {Array.} substring + */ + toIncludeMultiple(substring: readonly string[]): R; + + /** + * Use `.toThrowWithMessage` when checking if a callback function throws an error of a given type with a given error message. + * + * @param {Function} type + * @param {String | RegExp} message + */ + toThrowWithMessage( + type: (...args: any[]) => any, + message: string | RegExp, + ): R; + + /** + * Use `.toBeEmptyObject` when checking if a value is an empty `Object`. + */ + toBeEmptyObject(): R; + + /** + * Use `.toBeSymbol` when checking if a value is a `Symbol`. + */ + toBeSymbol(): R; + + /** + * Use `.toBeBetween` when checking if a date occurs between `startDate` and `endDate`. + * @param {Date} startDate + * @param {Date} endDate + */ + toBeBetween(startDate: Date, endDate: Date): R; + + /** + * Use `.toBeBeforeOrEqualTo` when checking if a date equals to or occurs before `date`. + * @param {Date} date + */ + toBeBeforeOrEqualTo(date: Date): R; + + /** + * Use `.toBeAfterOrEqualTo` when checking if a date equals to or occurs after `date`. + * @param {Date} date + */ + toBeAfterOrEqualTo(date: Date): R; + + /** + * Use `.toEqualIgnoringWhitespace` when checking if a `String` is equal (===) to given `String` ignoring white-space. + * + * @param {String} string + */ + toEqualIgnoringWhitespace(string: string): R; +} + +declare namespace jest { + // noinspection JSUnusedGlobalSymbols + interface Matchers { + /** + * Note: Currently unimplemented + * Passing assertion + * + * @param {String} message + */ + pass(message: string): R; + + /** + * Note: Currently unimplemented + * Failing assertion + * + * @param {String} message + */ + fail(message: string): never; + + /** + * Use .toBeEmpty when checking if a String '', Array [], Object {} or Iterable (i.e. Map, Set) is empty. + */ + toBeEmpty(): R; + + /** + * Use .toBeOneOf when checking if a value is a member of a given Array. + * @param {Array.<*>} members + */ + toBeOneOf(members: readonly E[]): R; + + /** + * Use `.toBeNil` when checking a value is `null` or `undefined`. + */ + toBeNil(): R; + + /** + * Use `.toSatisfy` when you want to use a custom matcher by supplying a predicate function that returns a `Boolean`. + * @param {Function} predicate + */ + toSatisfy(predicate: (x: E) => boolean): R; + + /** + * Use `.toBeArray` when checking if a value is an `Array`. + */ + toBeArray(): R; + + /** + * Use `.toBeArrayOfSize` when checking if a value is an `Array` of size x. + * @param {Number} x + */ + toBeArrayOfSize(x: number): R; + + /** + * Use `.toBeAfter` when checking if a date occurs after `date`. + * @param {Date} date + */ + toBeAfter(date: Date): R; + + /** + * Use `.toBeBefore` when checking if a date occurs before `date`. + * @param {Date} date + */ + toBeBefore(date: Date): R; + + /** + * Use `.toIncludeAllMembers` when checking if an `Array` contains all of the same members of a given set. + * @param {Array.<*>} members + */ + toIncludeAllMembers(members: readonly E[]): R; + + /** + * Use `.toIncludeAllPartialMembers` when checking if an `Array` contains all of the same partial members of a given set. + * @param {Array.<*>} members + */ + toIncludeAllPartialMembers(members: readonly E[]): R; + + /** + * Use `.toIncludeAnyMembers` when checking if an `Array` contains any of the members of a given set. + * @param {Array.<*>} members + */ + toIncludeAnyMembers(members: readonly E[]): R; + + /** + * Use `.toIncludeSameMembers` when checking if two arrays contain equal values, in any order. + * @param {Array.<*>} members + */ + toIncludeSameMembers(members: readonly E[]): R; + + /** + * Use `.toPartiallyContain` when checking if any array value matches the partial member. + * @param {*} member + */ + toPartiallyContain(member: E): R; + + /** + * Use `.toSatisfyAll` when you want to use a custom matcher by supplying a predicate function that returns a `Boolean` for all values in an array. + * @param {Function} predicate + */ + toSatisfyAll(predicate: (x: E) => boolean): R; + + /** + * Use `.toSatisfyAny` when you want to use a custom matcher by supplying a predicate function that returns `true` for any matching value in an array. + * @param {Function} predicate + */ + toSatisfyAny(predicate: (x: any) => boolean): R; + + /** + * Use `.toBeBoolean` when checking if a value is a `Boolean`. + */ + toBeBoolean(): R; + + /** + * Use `.toBeTrue` when checking a value is equal (===) to `true`. + */ + toBeTrue(): R; + + /** + * Use `.toBeFalse` when checking a value is equal (===) to `false`. + */ + toBeFalse(): R; + + /** + * Use `.toBeDate` when checking if a value is a `Date`. + */ + toBeDate(): R; + + /** + * Use `.toBeValidDate` when checking if a value is a `valid Date`. + */ + toBeValidDate(): R; + + /** + * Use `.toBeFunction` when checking if a value is a `Function`. + */ + toBeFunction(): R; + + /** + * Use `.toBeDateString` when checking if a value is a valid date string. + */ + toBeDateString(): R; + + /** + * Use `.toBeHexadecimal` when checking if a value is a valid HTML hex color. + */ + toBeHexadecimal(): R; + + /** + * Use `.toHaveBeenCalledBefore` when checking if a `Mock` was called before another `Mock`. + * + * Note: Required Jest version >=23 + * + * @param {Mock} mock + * @param {boolean} [failIfNoSecondInvocation=true] + */ + toHaveBeenCalledBefore( + mock: jest.MockInstance, + failIfNoSecondInvocation?: boolean, + ): R; + + /** + * Use `.toHaveBeenCalledAfter` when checking if a `Mock` was called after another `Mock`. + * + * Note: Required Jest version >=23 + * + * @param {Mock} mock + * @param {boolean} [failIfNoFirstInvocation=true] + */ + toHaveBeenCalledAfter( + mock: jest.MockInstance, + failIfNoFirstInvocation?: boolean, + ): R; + + /** + * Use `.toHaveBeenCalledOnce` to check if a `Mock` was called exactly one time. + */ + toHaveBeenCalledOnce(): R; + + /** + * Use `.toHaveBeenCalledExactlyOnceWith` to check if a `Mock` was called exactly one time with the expected value. + */ + toHaveBeenCalledExactlyOnceWith(...args: unknown[]): R; + + /** + * Use `.toBeNumber` when checking if a value is a `Number`. + */ + toBeNumber(): R; + + /** + * Use `.toBeNaN` when checking a value is `NaN`. + */ + toBeNaN(): R; + + /** + * Use `.toBeFinite` when checking if a value is a `Number`, not `NaN` or `Infinity`. + */ + toBeFinite(): R; + + /** + * Use `.toBePositive` when checking if a value is a positive `Number`. + */ + toBePositive(): R; + + /** + * Use `.toBeNegative` when checking if a value is a negative `Number`. + */ + toBeNegative(): R; + + /** + * Use `.toBeEven` when checking if a value is an even `Number`. + */ + toBeEven(): R; + + /** + * Use `.toBeOdd` when checking if a value is an odd `Number`. + */ + toBeOdd(): R; + + /** + * Use `.toBeWithin` when checking if a number is in between the given bounds of: start (inclusive) and end (exclusive). + * + * @param {Number} start + * @param {Number} end + */ + toBeWithin(start: number, end: number): R; + + /** + * Use `.toBeInRange` when checking if an array has elements in range min (inclusive) and max (inclusive). + * + * @param min + * @param max + */ + toBeInRange(min: number, max: number): R; + + /** + * Use `.toBeInteger` when checking if a value is an integer. + */ + toBeInteger(): R; + + /** + * Use `.toBeObject` when checking if a value is an `Object`. + */ + toBeObject(): R; + + /** + * Use `.toContainKey` when checking if an object contains the provided key. + * + * @param {String} key + */ + toContainKey(key: keyof E | string): R; + + /** + * Use `.toContainKeys` when checking if an object has all of the provided keys. + * + * @param {Array.} keys + */ + toContainKeys(keys: readonly (keyof E | string)[]): R; + + /** + * Use `.toContainAllKeys` when checking if an object only contains all of the provided keys. + * + * @param {Array.} keys + */ + toContainAllKeys(keys: readonly (keyof E | string)[]): R; + + /** + * Use `.toContainAnyKeys` when checking if an object contains at least one of the provided keys. + * + * @param {Array.} keys + */ + toContainAnyKeys(keys: readonly (keyof E | string)[]): R; + + /** + * Use `.toContainValue` when checking if an object contains the provided value. + * + * @param {*} value + */ + toContainValue(value: E): R; + + /** + * Use `.toContainValues` when checking if an object contains all of the provided values. + * + * @param {Array.<*>} values + */ + toContainValues(values: readonly E[]): R; + + /** + * Use `.toContainAllValues` when checking if an object only contains all of the provided values. + * + * @param {Array.<*>} values + */ + toContainAllValues(values: readonly E[]): R; + + /** + * Use `.toContainAnyValues` when checking if an object contains at least one of the provided values. + * + * @param {Array.<*>} values + */ + toContainAnyValues(values: readonly E[]): R; + + /** + * Use `.toContainEntry` when checking if an object contains the provided entry. + * + * @param {Array.} entry + */ + toContainEntry(entry: readonly [keyof E, E[keyof E]]): R; + + /** + * Use `.toContainEntries` when checking if an object contains all of the provided entries. + * + * @param {Array.>} entries + */ + toContainEntries( + entries: readonly (readonly [keyof E, E[keyof E]])[], + ): R; + + /** + * Use `.toContainAllEntries` when checking if an object only contains all of the provided entries. + * + * @param {Array.>} entries + */ + toContainAllEntries( + entries: readonly (readonly [keyof E, E[keyof E]])[], + ): R; + + /** + * Use `.toContainAnyEntries` when checking if an object contains at least one of the provided entries. + * + * @param {Array.>} entries + */ + toContainAnyEntries( + entries: readonly (readonly [keyof E, E[keyof E]])[], + ): R; + + /** + * Use `.toBeExtensible` when checking if an object is extensible. + */ + toBeExtensible(): R; + + /** + * Use `.toBeFrozen` when checking if an object is frozen. + */ + toBeFrozen(): R; + + /** + * Use `.toBeSealed` when checking if an object is sealed. + */ + toBeSealed(): R; + + /** + * Use `.toResolve` when checking if a promise resolves. + */ + toResolve(): Promise; + + /** + * Use `.toReject` when checking if a promise rejects. + */ + toReject(): Promise; + + /** + * Use `.toBeString` when checking if a value is a `String`. + */ + toBeString(): R; + + /** + * Use `.toEqualCaseInsensitive` when checking if a string is equal (===) to another ignoring the casing of both strings. + * + * @param {String} string + */ + toEqualCaseInsensitive(string: string): R; + + /** + * Use `.toStartWith` when checking if a `String` starts with a given `String` prefix. + * + * @param {String} prefix + */ + toStartWith(prefix: string): R; + + /** + * Use `.toEndWith` when checking if a `String` ends with a given `String` suffix. + * + * @param {String} suffix + */ + toEndWith(suffix: string): R; + + /** + * Use `.toInclude` when checking if a `String` includes the given `String` substring. + * + * @param {String} substring + */ + toInclude(substring: string): R; + + /** + * Use `.toIncludeRepeated` when checking if a `String` includes the given `String` substring the correct number of times. + * + * @param {String} substring + * @param {Number} times + */ + toIncludeRepeated(substring: string, times: number): R; + + /** + * Use `.toIncludeMultiple` when checking if a `String` includes all of the given substrings. + * + * @param {Array.} substring + */ + toIncludeMultiple(substring: readonly string[]): R; + + /** + * Use `.toThrowWithMessage` when checking if a callback function throws an error of a given type with a given error message. + * + * @param {Function} type + * @param {String | RegExp} message + */ + toThrowWithMessage( + type: + | (new (...args: any[]) => {message: string}) + | (abstract new (...args: any[]) => {message: string}) + | ((...args: any[]) => {message: string}), + message: string | RegExp, + ): R; + + /** + * Use `.toBeEmptyObject` when checking if a value is an empty `Object`. + */ + toBeEmptyObject(): R; + + /** + * Use `.toBeSymbol` when checking if a value is a `Symbol`. + */ + toBeSymbol(): R; + + /** + * Use `.toBeBetween` when checking if a date occurs between `startDate` and `endDate`. + * @param {Date} startDate + * @param {Date} endDate + */ + toBeBetween(startDate: Date, endDate: Date): R; + + /** + * Use `.toBeBeforeOrEqualTo` when checking if a date equals to or occurs before `date`. + * @param {Date} date + */ + toBeBeforeOrEqualTo(date: Date): R; + + /** + * Use `.toBeAfterOrEqualTo` when checking if a date equals to or occurs after `date`. + * @param {Date} date + */ + toBeAfterOrEqualTo(date: Date): R; + + /** + * Use `.toEqualIgnoringWhitespace` when checking if a `String` is equal (===) to given `String` ignoring white-space. + * + * @param {String} string + */ + toEqualIgnoringWhitespace(string: string): R; + } + + // noinspection JSUnusedGlobalSymbols + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface Expect extends CustomMatchers {} + + // noinspection JSUnusedGlobalSymbols + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface InverseAsymmetricMatchers extends Expect {} +} + +declare module "jest-extended" { + const matchers: CustomMatchers; + export = matchers; +} diff --git a/types/matchers.d.ts b/types/matchers.d.ts index 5ab113e08..7d00dc512 100644 --- a/types/matchers.d.ts +++ b/types/matchers.d.ts @@ -1,7 +1,6 @@ // eslint-disable-next-line @typescript-eslint/no-namespace declare namespace jest { interface Matchers { - toBeFunction(): R; /* * From: config/jest/matchers/to-have-no-a11y-violations.ts */ diff --git a/types/testing-library_jest-dom.d.ts b/types/testing-library_jest-dom.d.ts new file mode 100644 index 000000000..4062625a3 --- /dev/null +++ b/types/testing-library_jest-dom.d.ts @@ -0,0 +1,751 @@ +/* eslint-disable @typescript-eslint/ban-types, @typescript-eslint/no-empty-interface */ + +/** +We use matchers provided by third-party packages that extend the `Matchers` +interface within the `jest` namespace. TypeScript won't pick these up unless +they are imported in a TypeScript file. This can be done by: + +1. Installing `ts-node` and making the Jest config a TS file +(see https://www.npmjs.com/package/@testing-library/jest-dom#with-typescript +for example) then import from there and include the jest setup file in the +TypeScript includes. +2. Vendoring the type doc files like we're doing here. + +This is a copy of the type definitions from @testing-library/jest-dom, +amended to make sure the matchers extend the jest-expect interface. + */ + +import {type ARIARole} from "aria-query"; + +/// + +declare namespace matchers { + interface TestingLibraryMatchers { + /** + * @deprecated + * since v1.9.0 + * @description + * Assert whether a value is a DOM element, or not. Contrary to what its name implies, this matcher only checks + * that you passed to it a valid DOM element. + * + * It does not have a clear definition of what "the DOM" is. Therefore, it does not check whether that element + * is contained anywhere. + * @see + * [testing-library/jest-dom#toBeInTheDom](https://github.com/testing-library/jest-dom#toBeInTheDom) + */ + toBeInTheDOM(container?: HTMLElement | SVGElement): R; + /** + * @description + * Assert whether an element is present in the document or not. + * @example + * + * + * expect(queryByTestId('svg-element')).toBeInTheDocument() + * expect(queryByTestId('does-not-exist')).not.toBeInTheDocument() + * @see + * [testing-library/jest-dom#tobeinthedocument](https://github.com/testing-library/jest-dom#tobeinthedocument) + */ + toBeInTheDocument(): R; + /** + * @description + * This allows you to check if an element is currently visible to the user. + * + * An element is visible if **all** the following conditions are met: + * * it does not have its css property display set to none + * * it does not have its css property visibility set to either hidden or collapse + * * it does not have its css property opacity set to 0 + * * its parent element is also visible (and so on up to the top of the DOM tree) + * * it does not have the hidden attribute + * * if `
` it has the open attribute + * @example + *
+ * Zero Opacity + *
+ * + *
Visible Example
+ * + * expect(getByTestId('zero-opacity')).not.toBeVisible() + * expect(getByTestId('visible')).toBeVisible() + * @see + * [testing-library/jest-dom#tobevisible](https://github.com/testing-library/jest-dom#tobevisible) + */ + toBeVisible(): R; + /** + * @deprecated + * since v5.9.0 + * @description + * Assert whether an element has content or not. + * @example + * + * + * + * + * expect(getByTestId('empty')).toBeEmpty() + * expect(getByTestId('not-empty')).not.toBeEmpty() + * @see + * [testing-library/jest-dom#tobeempty](https://github.com/testing-library/jest-dom#tobeempty) + */ + toBeEmpty(): R; + /** + * @description + * Assert whether an element has content or not. + * @example + * + * + * + * + * expect(getByTestId('empty')).toBeEmptyDOMElement() + * expect(getByTestId('not-empty')).not.toBeEmptyDOMElement() + * @see + * [testing-library/jest-dom#tobeemptydomelement](https://github.com/testing-library/jest-dom#tobeemptydomelement) + */ + toBeEmptyDOMElement(): R; + /** + * @description + * Allows you to check whether an element is disabled from the user's perspective. + * + * Matches if the element is a form control and the `disabled` attribute is specified on this element or the + * element is a descendant of a form element with a `disabled` attribute. + * @example + * + * + * expect(getByTestId('button')).toBeDisabled() + * @see + * [testing-library/jest-dom#tobedisabled](https://github.com/testing-library/jest-dom#tobedisabled) + */ + toBeDisabled(): R; + /** + * @description + * Allows you to check whether an element is not disabled from the user's perspective. + * + * Works like `not.toBeDisabled()`. + * + * Use this matcher to avoid double negation in your tests. + * @example + * + * + * expect(getByTestId('button')).toBeEnabled() + * @see + * [testing-library/jest-dom#tobeenabled](https://github.com/testing-library/jest-dom#tobeenabled) + */ + toBeEnabled(): R; + /** + * @description + * Check if a form element, or the entire `form`, is currently invalid. + * + * An `input`, `select`, `textarea`, or `form` element is invalid if it has an `aria-invalid` attribute with no + * value or a value of "true", or if the result of `checkValidity()` is false. + * @example + * + * + *
+ * + *
+ * + * expect(getByTestId('no-aria-invalid')).not.toBeInvalid() + * expect(getByTestId('invalid-form')).toBeInvalid() + * @see + * [testing-library/jest-dom#tobeinvalid](https://github.com/testing-library/jest-dom#tobeinvalid) + */ + toBeInvalid(): R; + /** + * @description + * This allows you to check if a form element is currently required. + * + * An element is required if it is having a `required` or `aria-required="true"` attribute. + * @example + * + *
+ * + * expect(getByTestId('required-input')).toBeRequired() + * expect(getByTestId('supported-role')).not.toBeRequired() + * @see + * [testing-library/jest-dom#toberequired](https://github.com/testing-library/jest-dom#toberequired) + */ + toBeRequired(): R; + /** + * @description + * Allows you to check if a form element is currently required. + * + * An `input`, `select`, `textarea`, or `form` element is invalid if it has an `aria-invalid` attribute with no + * value or a value of "false", or if the result of `checkValidity()` is true. + * @example + * + * + *
+ * + *
+ * + * expect(getByTestId('no-aria-invalid')).not.toBeValid() + * expect(getByTestId('invalid-form')).toBeInvalid() + * @see + * [testing-library/jest-dom#tobevalid](https://github.com/testing-library/jest-dom#tobevalid) + */ + toBeValid(): R; + /** + * @description + * Allows you to assert whether an element contains another element as a descendant or not. + * @example + * + * + * + * + * const ancestor = getByTestId('ancestor') + * const descendant = getByTestId('descendant') + * const nonExistantElement = getByTestId('does-not-exist') + * expect(ancestor).toContainElement(descendant) + * expect(descendant).not.toContainElement(ancestor) + * expect(ancestor).not.toContainElement(nonExistantElement) + * @see + * [testing-library/jest-dom#tocontainelement](https://github.com/testing-library/jest-dom#tocontainelement) + */ + toContainElement(element: HTMLElement | SVGElement | null): R; + /** + * @description + * Assert whether a string representing a HTML element is contained in another element. + * @example + * + * + * expect(getByTestId('parent')).toContainHTML('') + * @see + * [testing-library/jest-dom#tocontainhtml](https://github.com/testing-library/jest-dom#tocontainhtml) + */ + toContainHTML(htmlText: string): R; + /** + * @description + * Allows you to check if a given element has an attribute or not. + * + * You can also optionally check that the attribute has a specific expected value or partial match using + * [expect.stringContaining](https://jestjs.io/docs/en/expect.html#expectnotstringcontainingstring) or + * [expect.stringMatching](https://jestjs.io/docs/en/expect.html#expectstringmatchingstring-regexp). + * @example + * + * + * expect(button).toHaveAttribute('disabled') + * expect(button).toHaveAttribute('type', 'submit') + * expect(button).not.toHaveAttribute('type', 'button') + * @see + * [testing-library/jest-dom#tohaveattribute](https://github.com/testing-library/jest-dom#tohaveattribute) + */ + toHaveAttribute(attr: string, value?: unknown): R; + /** + * @description + * Check whether the given element has certain classes within its `class` attribute. + * + * You must provide at least one class, unless you are asserting that an element does not have any classes. + * @example + * + * + *
no classes
+ * + * const deleteButton = getByTestId('delete-button') + * const noClasses = getByTestId('no-classes') + * expect(deleteButton).toHaveClass('btn') + * expect(deleteButton).toHaveClass('btn-danger xs') + * expect(deleteButton).toHaveClass(/danger/, 'xs') + * expect(deleteButton).toHaveClass('btn xs btn-danger', {exact: true}) + * expect(deleteButton).not.toHaveClass('btn xs btn-danger', {exact: true}) + * expect(noClasses).not.toHaveClass() + * @see + * [testing-library/jest-dom#tohaveclass](https://github.com/testing-library/jest-dom#tohaveclass) + */ + toHaveClass(...classNames: Array): R; + toHaveClass(classNames: string, options?: {exact: boolean}): R; + /** + * @description + * This allows you to check whether the given form element has the specified displayed value (the one the + * end user will see). It accepts , + * + * + * + * + * + * + * + * const input = screen.getByLabelText('First name') + * const textarea = screen.getByLabelText('Description') + * const selectSingle = screen.getByLabelText('Fruit') + * const selectMultiple = screen.getByLabelText('Fruits') + * + * expect(input).toHaveDisplayValue('Luca') + * expect(textarea).toHaveDisplayValue('An example description here.') + * expect(selectSingle).toHaveDisplayValue('Select a fruit...') + * expect(selectMultiple).toHaveDisplayValue(['Banana', 'Avocado']) + * + * @see + * [testing-library/jest-dom#tohavedisplayvalue](https://github.com/testing-library/jest-dom#tohavedisplayvalue) + */ + toHaveDisplayValue(value: string | RegExp | Array): R; + /** + * @description + * Assert whether an element has focus or not. + * @example + *
+ * + *
+ * + * const input = getByTestId('element-to-focus') + * input.focus() + * expect(input).toHaveFocus() + * input.blur() + * expect(input).not.toHaveFocus() + * @see + * [testing-library/jest-dom#tohavefocus](https://github.com/testing-library/jest-dom#tohavefocus) + */ + toHaveFocus(): R; + /** + * @description + * Check if a form or fieldset contains form controls for each given name, and having the specified value. + * + * Can only be invoked on a form or fieldset element. + * @example + *
+ * + * + * + * + *
+ * + * expect(getByTestId('login-form')).toHaveFormValues({ + * username: 'jane.doe', + * rememberMe: true, + * }) + * @see + * [testing-library/jest-dom#tohaveformvalues](https://github.com/testing-library/jest-dom#tohaveformvalues) + */ + toHaveFormValues(expectedValues: Record): R; + /** + * @description + * Check if an element has specific css properties with specific values applied. + * + * Only matches if the element has *all* the expected properties applied, not just some of them. + * @example + * + * + * const button = getByTestId('submit-button') + * expect(button).toHaveStyle('background-color: green') + * expect(button).toHaveStyle({ + * 'background-color': 'green', + * display: 'none' + * }) + * @see + * [testing-library/jest-dom#tohavestyle](https://github.com/testing-library/jest-dom#tohavestyle) + */ + toHaveStyle(css: string | Record): R; + /** + * @description + * Check whether the given element has a text content or not. + * + * When a string argument is passed through, it will perform a partial case-sensitive match to the element + * content. + * + * To perform a case-insensitive match, you can use a RegExp with the `/i` modifier. + * + * If you want to match the whole content, you can use a RegExp to do it. + * @example + * Text Content + * + * const element = getByTestId('text-content') + * expect(element).toHaveTextContent('Content') + * // to match the whole content + * expect(element).toHaveTextContent(/^Text Content$/) + * // to use case-insentive match + * expect(element).toHaveTextContent(/content$/i) + * expect(element).not.toHaveTextContent('content') + * @see + * [testing-library/jest-dom#tohavetextcontent](https://github.com/testing-library/jest-dom#tohavetextcontent) + */ + toHaveTextContent( + text: string | RegExp, + options?: {normalizeWhitespace: boolean}, + ): R; + /** + * @description + * Check whether the given form element has the specified value. + * + * Accepts ``, `