From 6844883d68f35bced1c846f9e9c3d7be7fcee5d0 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Wed, 13 Oct 2021 14:48:26 -0400 Subject: [PATCH 1/4] Extract react-i18n package **Why**: To be able to support the use of React i18n functions outside the document-capture package (e.g. in the components package), and to better separate shared utilities and simplify the scope of the document-capture package. --- .eslintrc | 11 +- .../components/acuant-capture-canvas.jsx | 2 +- .../components/acuant-capture.jsx | 2 +- .../components/block-link.jsx | 2 +- .../desktop-document-disclosure.jsx | 2 +- .../components/document-capture.jsx | 2 +- .../document-side-acuant-capture.jsx | 2 +- .../components/documents-step.jsx | 2 +- .../components/file-input.jsx | 2 +- .../components/form-error-message.jsx | 2 +- .../components/form-steps.jsx | 2 +- .../components/full-screen.jsx | 2 +- .../components/review-issues-step.jsx | 2 +- .../components/selfie-capture.jsx | 2 +- .../components/selfie-step.jsx | 2 +- .../components/submission-interstitial.jsx | 2 +- .../packages/document-capture/context/i18n.js | 7 -- .../document-capture/context/index.js | 1 - .../hooks/use-i18n.js => react-i18n/index.js} | 16 ++- .../packages/react-i18n/index.spec.jsx | 110 ++++++++++++++++++ .../packages/react-i18n/package.json | 8 ++ app/javascript/packs/document-capture.jsx | 2 +- package.json | 4 + .../components/acuant-capture-spec.jsx | 2 +- .../components/documents-step-spec.jsx | 7 +- .../components/form-error-message-spec.jsx | 2 +- .../components/review-issues-step-spec.jsx | 2 +- .../components/selfie-capture-spec.jsx | 2 +- .../document-capture/context/i18n-spec.jsx | 11 -- .../document-capture/hooks/use-i18n-spec.jsx | 96 --------------- spec/javascripts/spec_helper.d.ts | 5 + tsconfig.json | 1 + yarn.lock | 25 ++++ 33 files changed, 191 insertions(+), 151 deletions(-) delete mode 100644 app/javascript/packages/document-capture/context/i18n.js rename app/javascript/packages/{document-capture/hooks/use-i18n.js => react-i18n/index.js} (89%) create mode 100644 app/javascript/packages/react-i18n/index.spec.jsx create mode 100644 app/javascript/packages/react-i18n/package.json delete mode 100644 spec/javascripts/packages/document-capture/context/i18n-spec.jsx create mode 100644 spec/javascripts/spec_helper.d.ts diff --git a/.eslintrc b/.eslintrc index fc7ed5bddb2..08a32fcb529 100644 --- a/.eslintrc +++ b/.eslintrc @@ -26,9 +26,16 @@ }, "overrides": [ { - "files": "spec/javascripts/**/*", + "files": "*[.-]spec.*", "rules": { - "react/jsx-props-no-spreading": "off" + "react/jsx-props-no-spreading": "off", + "import/no-extraneous-dependencies": [ + "error", + { + "devDependencies": true, + "packageDir": "." + } + ] } } ] diff --git a/app/javascript/packages/document-capture/components/acuant-capture-canvas.jsx b/app/javascript/packages/document-capture/components/acuant-capture-canvas.jsx index 62197457c70..421205d75db 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture-canvas.jsx +++ b/app/javascript/packages/document-capture/components/acuant-capture-canvas.jsx @@ -1,7 +1,7 @@ import { useContext, useMemo, useEffect, useRef, useState } from 'react'; +import { useI18n } from '@18f/identity-react-i18n'; import AcuantContext from '../context/acuant'; import useAsset from '../hooks/use-asset'; -import useI18n from '../hooks/use-i18n'; import useInstanceId from '../hooks/use-instance-id'; import useImmutableCallback from '../hooks/use-immutable-callback'; diff --git a/app/javascript/packages/document-capture/components/acuant-capture.jsx b/app/javascript/packages/document-capture/components/acuant-capture.jsx index ecd02c65676..54dddca2561 100644 --- a/app/javascript/packages/document-capture/components/acuant-capture.jsx +++ b/app/javascript/packages/document-capture/components/acuant-capture.jsx @@ -7,13 +7,13 @@ import { useEffect, useImperativeHandle, } from 'react'; +import { useI18n } from '@18f/identity-react-i18n'; import AnalyticsContext from '../context/analytics'; import AcuantContext from '../context/acuant'; import AcuantCaptureCanvas from './acuant-capture-canvas'; import FileInput from './file-input'; import FullScreen from './full-screen'; import Button from './button'; -import useI18n from '../hooks/use-i18n'; import DeviceContext from '../context/device'; import UploadContext from '../context/upload'; import useIfStillMounted from '../hooks/use-if-still-mounted'; diff --git a/app/javascript/packages/document-capture/components/block-link.jsx b/app/javascript/packages/document-capture/components/block-link.jsx index 439e383ff90..ce27360d70e 100644 --- a/app/javascript/packages/document-capture/components/block-link.jsx +++ b/app/javascript/packages/document-capture/components/block-link.jsx @@ -1,4 +1,4 @@ -import useI18n from '../hooks/use-i18n'; +import { useI18n } from '@18f/identity-react-i18n'; /** @typedef {import('react').ReactNode} ReactNode */ diff --git a/app/javascript/packages/document-capture/components/desktop-document-disclosure.jsx b/app/javascript/packages/document-capture/components/desktop-document-disclosure.jsx index b902cca10b7..9df6ee1c151 100644 --- a/app/javascript/packages/document-capture/components/desktop-document-disclosure.jsx +++ b/app/javascript/packages/document-capture/components/desktop-document-disclosure.jsx @@ -1,6 +1,6 @@ import { useContext } from 'react'; +import { useI18n } from '@18f/identity-react-i18n'; import DeviceContext from '../context/device'; -import useI18n from '../hooks/use-i18n'; /** * Renders a document usage disclosure for desktop devices only. On mobile devices, an equivalent diff --git a/app/javascript/packages/document-capture/components/document-capture.jsx b/app/javascript/packages/document-capture/components/document-capture.jsx index ebfe00c77fb..26bdf01672a 100644 --- a/app/javascript/packages/document-capture/components/document-capture.jsx +++ b/app/javascript/packages/document-capture/components/document-capture.jsx @@ -1,5 +1,6 @@ import { useState, useMemo, useContext } from 'react'; import { Alert } from '@18f/identity-components'; +import { useI18n } from '@18f/identity-react-i18n'; import FormSteps from './form-steps'; import { UploadFormEntriesError } from '../services/upload'; import DocumentsStep, { documentsStepValidator } from './documents-step'; @@ -9,7 +10,6 @@ import ServiceProviderContext from '../context/service-provider'; import Submission from './submission'; import SubmissionStatus from './submission-status'; import DesktopDocumentDisclosure from './desktop-document-disclosure'; -import useI18n from '../hooks/use-i18n'; import { RetrySubmissionError } from './submission-complete'; import { BackgroundEncryptedUploadError } from '../higher-order/with-background-encrypted-upload'; import SuspenseErrorBoundary from './suspense-error-boundary'; diff --git a/app/javascript/packages/document-capture/components/document-side-acuant-capture.jsx b/app/javascript/packages/document-capture/components/document-side-acuant-capture.jsx index a68b5cd3c14..c19f5897c92 100644 --- a/app/javascript/packages/document-capture/components/document-side-acuant-capture.jsx +++ b/app/javascript/packages/document-capture/components/document-side-acuant-capture.jsx @@ -1,6 +1,6 @@ +import { useI18n } from '@18f/identity-react-i18n'; import AcuantCapture from './acuant-capture'; import FormErrorMessage, { CameraAccessDeclinedError } from './form-error-message'; -import useI18n from '../hooks/use-i18n'; /** @typedef {import('./form-steps').FormStepError<*>} FormStepError */ /** @typedef {import('./form-steps').RegisterFieldCallback} RegisterFieldCallback */ diff --git a/app/javascript/packages/document-capture/components/documents-step.jsx b/app/javascript/packages/document-capture/components/documents-step.jsx index 62c2e5c7a09..2b1f5d635fa 100644 --- a/app/javascript/packages/document-capture/components/documents-step.jsx +++ b/app/javascript/packages/document-capture/components/documents-step.jsx @@ -1,7 +1,7 @@ import { useContext } from 'react'; +import { useI18n } from '@18f/identity-react-i18n'; import BlockLink from './block-link'; import DocumentSideAcuantCapture from './document-side-acuant-capture'; -import useI18n from '../hooks/use-i18n'; import DeviceContext from '../context/device'; import ServiceProviderContext from '../context/service-provider'; import withBackgroundEncryptedUpload from '../higher-order/with-background-encrypted-upload'; diff --git a/app/javascript/packages/document-capture/components/file-input.jsx b/app/javascript/packages/document-capture/components/file-input.jsx index c7ceea03ec8..00a13acdd5d 100644 --- a/app/javascript/packages/document-capture/components/file-input.jsx +++ b/app/javascript/packages/document-capture/components/file-input.jsx @@ -7,10 +7,10 @@ import { useRef, useImperativeHandle, } from 'react'; +import { useI18n } from '@18f/identity-react-i18n'; import FileImage from './file-image'; import DeviceContext from '../context/device'; import useInstanceId from '../hooks/use-instance-id'; -import useI18n from '../hooks/use-i18n'; import usePrevious from '../hooks/use-previous'; /** @typedef {import('react').MouseEvent} ReactMouseEvent */ diff --git a/app/javascript/packages/document-capture/components/form-error-message.jsx b/app/javascript/packages/document-capture/components/form-error-message.jsx index c8f0ff465fd..96acec110dc 100644 --- a/app/javascript/packages/document-capture/components/form-error-message.jsx +++ b/app/javascript/packages/document-capture/components/form-error-message.jsx @@ -1,6 +1,6 @@ +import { useI18n } from '@18f/identity-react-i18n'; import { UploadFormEntryError } from '../services/upload'; import { BackgroundEncryptedUploadError } from '../higher-order/with-background-encrypted-upload'; -import useI18n from '../hooks/use-i18n'; /** @typedef {import('react').ReactNode} ReactNode */ diff --git a/app/javascript/packages/document-capture/components/form-steps.jsx b/app/javascript/packages/document-capture/components/form-steps.jsx index b029c796fa5..40d06dbfba4 100644 --- a/app/javascript/packages/document-capture/components/form-steps.jsx +++ b/app/javascript/packages/document-capture/components/form-steps.jsx @@ -1,10 +1,10 @@ import { useEffect, useRef, useState } from 'react'; import { Alert } from '@18f/identity-components'; +import { useI18n } from '@18f/identity-react-i18n'; import Button from './button'; import PageHeading from './page-heading'; import FormErrorMessage, { RequiredValueMissingError } from './form-error-message'; import PromptOnNavigate from './prompt-on-navigate'; -import useI18n from '../hooks/use-i18n'; import useHistoryParam from '../hooks/use-history-param'; import useForceRender from '../hooks/use-force-render'; import useDidUpdateEffect from '../hooks/use-did-update-effect'; diff --git a/app/javascript/packages/document-capture/components/full-screen.jsx b/app/javascript/packages/document-capture/components/full-screen.jsx index 20410e03627..d4aedd14c46 100644 --- a/app/javascript/packages/document-capture/components/full-screen.jsx +++ b/app/javascript/packages/document-capture/components/full-screen.jsx @@ -1,6 +1,6 @@ import { forwardRef, useImperativeHandle, useRef, useEffect } from 'react'; import { createPortal } from 'react-dom'; -import useI18n from '../hooks/use-i18n'; +import { useI18n } from '@18f/identity-react-i18n'; import useAsset from '../hooks/use-asset'; import useToggleBodyClassByPresence from '../hooks/use-toggle-body-class-by-presence'; import useImmutableCallback from '../hooks/use-immutable-callback'; diff --git a/app/javascript/packages/document-capture/components/review-issues-step.jsx b/app/javascript/packages/document-capture/components/review-issues-step.jsx index ed401c9322e..d0b5fe7964c 100644 --- a/app/javascript/packages/document-capture/components/review-issues-step.jsx +++ b/app/javascript/packages/document-capture/components/review-issues-step.jsx @@ -1,6 +1,6 @@ import { useContext } from 'react'; import { hasMediaAccess } from '@18f/identity-device'; -import useI18n from '../hooks/use-i18n'; +import { useI18n } from '@18f/identity-react-i18n'; import DeviceContext from '../context/device'; import DocumentSideAcuantCapture from './document-side-acuant-capture'; import AcuantCapture from './acuant-capture'; diff --git a/app/javascript/packages/document-capture/components/selfie-capture.jsx b/app/javascript/packages/document-capture/components/selfie-capture.jsx index 9c51902a984..60ca20fd71f 100644 --- a/app/javascript/packages/document-capture/components/selfie-capture.jsx +++ b/app/javascript/packages/document-capture/components/selfie-capture.jsx @@ -8,9 +8,9 @@ import { useImperativeHandle, } from 'react'; import { Icon } from '@18f/identity-components'; +import { useI18n } from '@18f/identity-react-i18n'; import FileImage from './file-image'; import useIfStillMounted from '../hooks/use-if-still-mounted'; -import useI18n from '../hooks/use-i18n'; import useInstanceId from '../hooks/use-instance-id'; import useFocusFallbackRef from '../hooks/use-focus-fallback-ref'; import './selfie-capture.scss'; diff --git a/app/javascript/packages/document-capture/components/selfie-step.jsx b/app/javascript/packages/document-capture/components/selfie-step.jsx index 349d4df3633..4e240f4d185 100644 --- a/app/javascript/packages/document-capture/components/selfie-step.jsx +++ b/app/javascript/packages/document-capture/components/selfie-step.jsx @@ -1,6 +1,6 @@ import { useContext } from 'react'; import { hasMediaAccess } from '@18f/identity-device'; -import useI18n from '../hooks/use-i18n'; +import { useI18n } from '@18f/identity-react-i18n'; import DeviceContext from '../context/device'; import AcuantCapture from './acuant-capture'; import SelfieCapture from './selfie-capture'; diff --git a/app/javascript/packages/document-capture/components/submission-interstitial.jsx b/app/javascript/packages/document-capture/components/submission-interstitial.jsx index da69a5c2f5b..cc8b690dc69 100644 --- a/app/javascript/packages/document-capture/components/submission-interstitial.jsx +++ b/app/javascript/packages/document-capture/components/submission-interstitial.jsx @@ -1,5 +1,5 @@ import { useRef, useEffect } from 'react'; -import useI18n from '../hooks/use-i18n'; +import { useI18n } from '@18f/identity-react-i18n'; import useAsset from '../hooks/use-asset'; import PageHeading from './page-heading'; diff --git a/app/javascript/packages/document-capture/context/i18n.js b/app/javascript/packages/document-capture/context/i18n.js deleted file mode 100644 index 7c4c52c8836..00000000000 --- a/app/javascript/packages/document-capture/context/i18n.js +++ /dev/null @@ -1,7 +0,0 @@ -import { createContext } from 'react'; - -const I18nContext = createContext({}); - -I18nContext.displayName = 'I18nContext'; - -export default I18nContext; diff --git a/app/javascript/packages/document-capture/context/index.js b/app/javascript/packages/document-capture/context/index.js index 82c04af63a5..ebb651bca38 100644 --- a/app/javascript/packages/document-capture/context/index.js +++ b/app/javascript/packages/document-capture/context/index.js @@ -1,5 +1,4 @@ export { default as AssetContext } from './asset'; -export { default as I18nContext } from './i18n'; export { default as DeviceContext } from './device'; export { default as AcuantContext, Provider as AcuantContextProvider } from './acuant'; export { default as UploadContext, Provider as UploadContextProvider } from './upload'; diff --git a/app/javascript/packages/document-capture/hooks/use-i18n.js b/app/javascript/packages/react-i18n/index.js similarity index 89% rename from app/javascript/packages/document-capture/hooks/use-i18n.js rename to app/javascript/packages/react-i18n/index.js index d46080b767d..ee91fe18c73 100644 --- a/app/javascript/packages/document-capture/hooks/use-i18n.js +++ b/app/javascript/packages/react-i18n/index.js @@ -1,9 +1,12 @@ -import { createElement, cloneElement, useContext, useMemo } from 'react'; +import { createElement, cloneElement, createContext, useContext, useMemo } from 'react'; import { I18n } from '@18f/identity-i18n'; -import I18nContext from '../context/i18n'; /** @typedef {import('react').FC|import('react').ComponentClass} Component */ +export const I18nContext = createContext({}); + +I18nContext.displayName = 'I18nContext'; + /** * Given an HTML string and an object of tag names to React component, returns a new React node * where the mapped tag names are replaced by the resulting element of the rendered component. @@ -49,14 +52,9 @@ export function formatHTML(html, handlers) { return parts.filter(Boolean); } -function useI18n() { +export function useI18n() { const strings = useContext(I18nContext); const { t } = useMemo(() => new I18n({ strings }), [strings]); - return { - t, - formatHTML, - }; + return { t, formatHTML }; } - -export default useI18n; diff --git a/app/javascript/packages/react-i18n/index.spec.jsx b/app/javascript/packages/react-i18n/index.spec.jsx new file mode 100644 index 00000000000..9a8a1e622bc --- /dev/null +++ b/app/javascript/packages/react-i18n/index.spec.jsx @@ -0,0 +1,110 @@ +import { useContext } from 'react'; +import { render } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import { I18nContext, useI18n } from './index.js'; + +describe('I18nContext', () => { + it('defaults to empty object', () => { + const { result } = renderHook(() => useContext(I18nContext)); + + expect(result.current).to.deep.equal({}); + }); +}); + +describe('useI18n', () => { + describe('formatHTML', () => { + let formatHTML; + before(() => { + const { result } = renderHook(() => useI18n()); + formatHTML = result.current.formatHTML; + }); + + it('returns html string treated as escaped text without handler', () => { + const formatted = formatHTML('Hello world!', {}); + + const { container } = render(formatted); + + expect(container.innerHTML).to.equal('Hello <strong>world</strong>!'); + }); + + it('returns html string chunked by component handlers', () => { + const formatted = formatHTML('Hello world!', { + strong: ({ children }) => {children}, + }); + + const { container } = render(formatted); + + expect(container.innerHTML).to.equal('Hello world!'); + }); + + it('returns html string chunked by string handlers', () => { + const formatted = formatHTML('Hello world!', { + strong: 'strong', + }); + + const { container } = render(formatted); + + expect(container.innerHTML).to.equal('Hello world!'); + }); + + it('returns html string chunked by multiple handlers', () => { + const formatted = formatHTML( + 'Message: Hello world!', + { + 'lg-custom': () => 'Greetings', + strong: ({ children }) => {children}, + }, + ); + + const { container } = render(formatted); + + expect(container.innerHTML).to.equal('Message: Greetings world!'); + }); + + it('removes dangling empty text fragment', () => { + const formatted = formatHTML('Hello world', { + strong: ({ children }) => {children}, + }); + + const { container } = render(formatted); + + expect(container.childNodes).to.have.lengthOf(2); + }); + + it('allows (but discards) attributes in the input string', () => { + const formatted = formatHTML( + 'Hello world', + { + strong: ({ children }) => {children}, + }, + ); + + const { container } = render(formatted); + + expect(container.querySelectorAll('[data-after]')).to.have.lengthOf(2); + expect(container.querySelectorAll('[data-before]')).to.have.lengthOf(0); + }); + }); + + describe('t', () => { + it('returns localized key value', () => { + const { result } = renderHook(() => useI18n(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + const { t } = result.current; + + expect(t('sample')).to.equal('translation'); + }); + + it('falls back to key value', () => { + const { result } = renderHook(() => useI18n()); + + const { t } = result.current; + + expect(t('sample')).to.equal('sample'); + }); + }); +}); diff --git a/app/javascript/packages/react-i18n/package.json b/app/javascript/packages/react-i18n/package.json new file mode 100644 index 00000000000..0906ab47a9f --- /dev/null +++ b/app/javascript/packages/react-i18n/package.json @@ -0,0 +1,8 @@ +{ + "name": "@18f/identity-react-i18n", + "private": true, + "version": "1.0.0", + "dependencies": { + "react": "^17.0.1" + } +} diff --git a/app/javascript/packs/document-capture.jsx b/app/javascript/packs/document-capture.jsx index 726e8b2d98b..7e9c809f4c7 100644 --- a/app/javascript/packs/document-capture.jsx +++ b/app/javascript/packs/document-capture.jsx @@ -2,7 +2,6 @@ import { render } from 'react-dom'; import { DocumentCapture, AssetContext, - I18nContext, DeviceContext, AcuantContextProvider, UploadContextProvider, @@ -12,6 +11,7 @@ import { import { loadPolyfills } from '@18f/identity-polyfill'; import { isCameraCapableMobile } from '@18f/identity-device'; import { trackEvent } from '@18f/identity-analytics'; +import { I18nContext } from '@18f/identity-react-i18n'; /** @typedef {import('@18f/identity-i18n').I18n} I18n */ diff --git a/package.json b/package.json index aad8c2d3b60..3929d291eb3 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,9 @@ "@testing-library/react": "^11.2.2", "@testing-library/react-hooks": "^3.7.0", "@testing-library/user-event": "^12.6.0", + "@types/chai": "^4.2.22", + "@types/dirty-chai": "^2.0.2", + "@types/mocha": "^9.0.0", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", "@types/sinon": "^9.0.11", @@ -60,6 +63,7 @@ "sinon-chai": "^3.5.0", "svgo": "^1.3.2", "typescript": "^4.1.3", + "webpack": "^4.46.0", "webpack-dev-server": "^3.11.1" } } diff --git a/spec/javascripts/packages/document-capture/components/acuant-capture-spec.jsx b/spec/javascripts/packages/document-capture/components/acuant-capture-spec.jsx index 54c64c5ef35..743e1843402 100644 --- a/spec/javascripts/packages/document-capture/components/acuant-capture-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/acuant-capture-spec.jsx @@ -9,7 +9,7 @@ import AcuantCapture, { } from '@18f/identity-document-capture/components/acuant-capture'; import { AcuantContextProvider, AnalyticsContext } from '@18f/identity-document-capture'; import DeviceContext from '@18f/identity-document-capture/context/device'; -import I18nContext from '@18f/identity-document-capture/context/i18n'; +import { I18nContext } from '@18f/identity-react-i18n'; import { render, useAcuant } from '../../../support/document-capture'; import { getFixtureFile } from '../../../support/file'; diff --git a/spec/javascripts/packages/document-capture/components/documents-step-spec.jsx b/spec/javascripts/packages/document-capture/components/documents-step-spec.jsx index 12cc36efdb5..ed98383829d 100644 --- a/spec/javascripts/packages/document-capture/components/documents-step-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/documents-step-spec.jsx @@ -1,10 +1,7 @@ import userEvent from '@testing-library/user-event'; import sinon from 'sinon'; -import { - I18nContext, - DeviceContext, - ServiceProviderContextProvider, -} from '@18f/identity-document-capture'; +import { DeviceContext, ServiceProviderContextProvider } from '@18f/identity-document-capture'; +import { I18nContext } from '@18f/identity-react-i18n'; import DocumentsStep from '@18f/identity-document-capture/components/documents-step'; import { render } from '../../../support/document-capture'; import { getFixtureFile } from '../../../support/file'; diff --git a/spec/javascripts/packages/document-capture/components/form-error-message-spec.jsx b/spec/javascripts/packages/document-capture/components/form-error-message-spec.jsx index 5c1f6c1a73f..b9ab3346bcd 100644 --- a/spec/javascripts/packages/document-capture/components/form-error-message-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/form-error-message-spec.jsx @@ -1,4 +1,4 @@ -import { I18nContext } from '@18f/identity-document-capture'; +import { I18nContext } from '@18f/identity-react-i18n'; import FormErrorMessage, { RequiredValueMissingError, CameraAccessDeclinedError, diff --git a/spec/javascripts/packages/document-capture/components/review-issues-step-spec.jsx b/spec/javascripts/packages/document-capture/components/review-issues-step-spec.jsx index f3476efb6df..30d0c138f84 100644 --- a/spec/javascripts/packages/document-capture/components/review-issues-step-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/review-issues-step-spec.jsx @@ -1,13 +1,13 @@ import userEvent from '@testing-library/user-event'; import sinon from 'sinon'; import { - I18nContext, ServiceProviderContextProvider, UploadContextProvider, } from '@18f/identity-document-capture'; import ReviewIssuesStep, { reviewIssuesStepValidator, } from '@18f/identity-document-capture/components/review-issues-step'; +import { I18nContext } from '@18f/identity-react-i18n'; import { render } from '../../../support/document-capture'; import { useSandbox } from '../../../support/sinon'; import { getFixtureFile } from '../../../support/file'; diff --git a/spec/javascripts/packages/document-capture/components/selfie-capture-spec.jsx b/spec/javascripts/packages/document-capture/components/selfie-capture-spec.jsx index be0e12b2c4f..ed1f6d68273 100644 --- a/spec/javascripts/packages/document-capture/components/selfie-capture-spec.jsx +++ b/spec/javascripts/packages/document-capture/components/selfie-capture-spec.jsx @@ -1,7 +1,7 @@ import sinon from 'sinon'; import userEvent from '@testing-library/user-event'; import { cleanup } from '@testing-library/react'; -import { I18nContext } from '@18f/identity-document-capture'; +import { I18nContext } from '@18f/identity-react-i18n'; import SelfieCapture from '@18f/identity-document-capture/components/selfie-capture'; import { render } from '../../../support/document-capture'; import { useSandbox } from '../../../support/sinon'; diff --git a/spec/javascripts/packages/document-capture/context/i18n-spec.jsx b/spec/javascripts/packages/document-capture/context/i18n-spec.jsx deleted file mode 100644 index 6f764e8b1aa..00000000000 --- a/spec/javascripts/packages/document-capture/context/i18n-spec.jsx +++ /dev/null @@ -1,11 +0,0 @@ -import { useContext } from 'react'; -import { renderHook } from '@testing-library/react-hooks'; -import I18nContext from '@18f/identity-document-capture/context/i18n'; - -describe('document-capture/context/i18n', () => { - it('defaults to empty object', () => { - const { result } = renderHook(() => useContext(I18nContext)); - - expect(result.current).to.deep.equal({}); - }); -}); diff --git a/spec/javascripts/packages/document-capture/hooks/use-i18n-spec.jsx b/spec/javascripts/packages/document-capture/hooks/use-i18n-spec.jsx index 6871e914915..e69de29bb2d 100644 --- a/spec/javascripts/packages/document-capture/hooks/use-i18n-spec.jsx +++ b/spec/javascripts/packages/document-capture/hooks/use-i18n-spec.jsx @@ -1,96 +0,0 @@ -import { renderHook } from '@testing-library/react-hooks'; -import I18nContext from '@18f/identity-document-capture/context/i18n'; -import useI18n, { formatHTML } from '@18f/identity-document-capture/hooks/use-i18n'; -import { render } from '../../../support/document-capture'; - -describe('document-capture/hooks/use-i18n', () => { - describe('formatHTML', () => { - it('returns html string treated as escaped text without handler', () => { - const formatted = formatHTML('Hello world!', {}); - - const { container } = render(formatted); - - expect(container.innerHTML).to.equal('Hello <strong>world</strong>!'); - }); - - it('returns html string chunked by component handlers', () => { - const formatted = formatHTML('Hello world!', { - strong: ({ children }) => {children}, - }); - - const { container } = render(formatted); - - expect(container.innerHTML).to.equal('Hello world!'); - }); - - it('returns html string chunked by string handlers', () => { - const formatted = formatHTML('Hello world!', { - strong: 'strong', - }); - - const { container } = render(formatted); - - expect(container.innerHTML).to.equal('Hello world!'); - }); - - it('returns html string chunked by multiple handlers', () => { - const formatted = formatHTML( - 'Message: Hello world!', - { - 'lg-custom': () => 'Greetings', - strong: ({ children }) => {children}, - }, - ); - - const { container } = render(formatted); - - expect(container.innerHTML).to.equal('Message: Greetings world!'); - }); - - it('removes dangling empty text fragment', () => { - const formatted = formatHTML('Hello world', { - strong: ({ children }) => {children}, - }); - - const { container } = render(formatted); - - expect(container.childNodes).to.have.lengthOf(2); - }); - - it('allows (but discards) attributes in the input string', () => { - const formatted = formatHTML( - 'Hello world', - { - strong: ({ children }) => {children}, - }, - ); - - const { container } = render(formatted); - - expect(container.querySelectorAll('[data-after]')).to.have.lengthOf(2); - expect(container.querySelectorAll('[data-before]')).to.have.lengthOf(0); - }); - }); - - describe('t', () => { - it('returns localized key value', () => { - const { result } = renderHook(() => useI18n(), { - wrapper: ({ children }) => ( - {children} - ), - }); - - const { t } = result.current; - - expect(t('sample')).to.equal('translation'); - }); - - it('falls back to key value', () => { - const { result } = renderHook(() => useI18n()); - - const { t } = result.current; - - expect(t('sample')).to.equal('sample'); - }); - }); -}); diff --git a/spec/javascripts/spec_helper.d.ts b/spec/javascripts/spec_helper.d.ts new file mode 100644 index 00000000000..b0c66d489bc --- /dev/null +++ b/spec/javascripts/spec_helper.d.ts @@ -0,0 +1,5 @@ +import { expect as _expect } from 'chai'; + +declare global { + const expect: typeof _expect; +} diff --git a/tsconfig.json b/tsconfig.json index d94a9bc88b4..876f675bf14 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,7 @@ "target": "ESNext" }, "include": [ + "spec/javascripts/spec_helper.d.ts", "app/javascript/app/phone-internationalization.js", "app/javascript/packages", "app/javascript/packs" diff --git a/yarn.lock b/yarn.lock index 78054bcce12..9e12c4bc9df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1202,6 +1202,26 @@ resolved "https://registry.yarnpkg.com/@types/asn1js/-/asn1js-2.0.0.tgz#10ca75692575744d0117098148a8dc84cbee6682" integrity sha512-Jjzp5EqU0hNpADctc/UqhiFbY1y2MqIxBVa2S4dBlbnZHTLPMuggoL5q43X63LpsOIINRDirBjP56DUUKIUWIA== +"@types/chai-as-promised@*": + version "7.1.4" + resolved "https://registry.yarnpkg.com/@types/chai-as-promised/-/chai-as-promised-7.1.4.tgz#caf64e76fb056b8c8ced4b761ed499272b737601" + integrity sha512-1y3L1cHePcIm5vXkh1DSGf/zQq5n5xDKG1fpCvf18+uOkpce0Z1ozNFPkyWsVswK7ntN1sZBw3oU6gmN+pDUcA== + dependencies: + "@types/chai" "*" + +"@types/chai@*", "@types/chai@^4.2.22": + version "4.2.22" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.22.tgz#47020d7e4cf19194d43b5202f35f75bd2ad35ce7" + integrity sha512-tFfcE+DSTzWAgifkjik9AySNqIyNoYwmR+uecPwwD/XRNfvOjmC/FjCxpiUGDkDVDphPfCUecSQVFw+lN3M3kQ== + +"@types/dirty-chai@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@types/dirty-chai/-/dirty-chai-2.0.2.tgz#eeac4802329a41ed7815ac0c1a6360335bf77d0c" + integrity sha512-BruwIN/UQEU0ePghxEX+OyjngpOfOUKJQh3cmfeq2h2Su/g001iljVi3+Y2y2EFp3IPgjf4sMrRU33Hxv1FUqw== + dependencies: + "@types/chai" "*" + "@types/chai-as-promised" "*" + "@types/glob@^7.1.1": version "7.1.3" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.3.tgz#e6ba80f36b7daad2c685acd9266382e68985c183" @@ -1244,6 +1264,11 @@ resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== +"@types/mocha@^9.0.0": + version "9.0.0" + resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-9.0.0.tgz#3205bcd15ada9bc681ac20bef64e9e6df88fd297" + integrity sha512-scN0hAWyLVAvLR9AyW7HoFF5sJZglyBsbPuHO4fv7JRvfmPBMfp1ozWqOf/e4wwPNxezBZXRfWzMb6iFLgEVRA== + "@types/node@*": version "14.11.2" resolved "https://registry.yarnpkg.com/@types/node/-/node-14.11.2.tgz#2de1ed6670439387da1c9f549a2ade2b0a799256" From 60780c9197574a01626df22c065cc8027398f87e Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Wed, 13 Oct 2021 16:54:07 -0400 Subject: [PATCH 2/4] Ignore strings in all spec or fixture files **Why**: Avoid tedium of adding exceptions for all test strings --- config/i18n-tasks.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 8bf9756a2bf..b6326e22a7b 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -63,8 +63,8 @@ search: - app/assets/fonts - app/views/shared/newrelic/_browser_instrumentation.html.erb - app/javascript/app/local-time.js - - app/javascript/packages/rails-i18n-webpack-plugin - - app/javascript/packages/i18n + - '**/*.spec.*' + - '**/fixtures/**' ## Alternatively, the only files or `File.fnmatch patterns` to search in `paths`: ## If specified, this settings takes priority over `exclude`, but `exclude` still applies. From b3cb26f057d6b3c8733b404d0b8f664e00ee5063 Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Wed, 13 Oct 2021 16:56:00 -0400 Subject: [PATCH 3/4] eh, on second thought, exempt specific paths **why**: Some spec files may contain lingering references to unused or missing strings, which we'd want to be made aware of. The ones we don't care about are pretty isolated to these packages --- config/i18n-tasks.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index b6326e22a7b..f8e81eaed98 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -63,8 +63,9 @@ search: - app/assets/fonts - app/views/shared/newrelic/_browser_instrumentation.html.erb - app/javascript/app/local-time.js - - '**/*.spec.*' - - '**/fixtures/**' + - app/javascript/packages/rails-i18n-webpack-plugin + - app/javascript/packages/react-i18n + - app/javascript/packages/i18n ## Alternatively, the only files or `File.fnmatch patterns` to search in `paths`: ## If specified, this settings takes priority over `exclude`, but `exclude` still applies. From 51e6a7e43fce5ffcc66db646324f9b52023dd95d Mon Sep 17 00:00:00 2001 From: Andrew Duthie Date: Thu, 14 Oct 2021 09:37:30 -0400 Subject: [PATCH 4/4] Limit expect globals define to spec files **Why**: So that a developer who tries to use expect in application code will encounter a failed build --- .eslintrc | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.eslintrc b/.eslintrc index 08a32fcb529..495469ce751 100644 --- a/.eslintrc +++ b/.eslintrc @@ -5,9 +5,6 @@ "browser": true, "commonjs": true }, - "globals": { - "expect": true - }, "rules": { "no-restricted-syntax": [ "error", @@ -26,7 +23,10 @@ }, "overrides": [ { - "files": "*[.-]spec.*", + "files": ["*.spec.*", "*-spec.*", "*_spec.*", "spec/**"], + "globals": { + "expect": true + }, "rules": { "react/jsx-props-no-spreading": "off", "import/no-extraneous-dependencies": [