diff --git a/flow-report/src/header.tsx b/flow-report/src/header.tsx index 7373070328a6..fcec6482db0e 100644 --- a/flow-report/src/header.tsx +++ b/flow-report/src/header.tsx @@ -8,7 +8,7 @@ import {FunctionComponent} from 'preact'; import {Util} from '../../report/renderer/util'; import {FlowStepIcon, FlowStepThumbnail} from './common'; -import {useUIStrings} from './i18n/i18n'; +import {useLocalizedStrings} from './i18n/i18n'; import {getModeDescription, useFlowResult} from './util'; const SIDE_THUMBNAIL_HEIGHT = 80; @@ -39,7 +39,7 @@ export const Header: FunctionComponent<{currentLhr: LH.FlowResult.LhrRef}> = const prevStep = flowResult.steps[index - 1]; const nextStep = flowResult.steps[index + 1]; - const strings = useUIStrings(); + const strings = useLocalizedStrings(); const modeDescription = getModeDescription(step.lhr.gatherMode, strings); return ( diff --git a/flow-report/src/help-dialog.tsx b/flow-report/src/help-dialog.tsx index 4f45d208a6c7..fd4460a5b302 100644 --- a/flow-report/src/help-dialog.tsx +++ b/flow-report/src/help-dialog.tsx @@ -6,7 +6,7 @@ import {FunctionComponent, JSX} from 'preact'; -import {useUIStrings} from './i18n/i18n'; +import {useLocalizedStrings} from './i18n/i18n'; import {CloseIcon, NavigationIcon, SnapshotIcon, TimespanIcon} from './icons'; const HelpDialogColumn: FunctionComponent<{ @@ -18,7 +18,7 @@ const HelpDialogColumn: FunctionComponent<{ useCases: string[]; availableCategories: string[]; }> = (props) => { - const strings = useUIStrings(); + const strings = useLocalizedStrings(); return (
@@ -56,7 +56,7 @@ const HelpDialogColumn: FunctionComponent<{ export const HelpDialog: FunctionComponent<{onClose: () => void}> = ({ onClose, }) => { - const strings = useUIStrings(); + const strings = useLocalizedStrings(); return (
diff --git a/flow-report/src/i18n/i18n.tsx b/flow-report/src/i18n/i18n.tsx index 42d4c297c18c..a31260deae0c 100644 --- a/flow-report/src/i18n/i18n.tsx +++ b/flow-report/src/i18n/i18n.tsx @@ -10,37 +10,66 @@ import {useContext, useMemo} from 'preact/hooks'; import {formatMessage} from '../../../shared/localization/format'; import {I18n} from '../../../report/renderer/i18n'; import {UIStrings} from './ui-strings'; -import {useLocale} from '../util'; +import {useFlowResult} from '../util'; import strings from './localized-strings'; +import {Util} from '../../../report/renderer/util'; -const I18nContext = createContext|undefined>(undefined); +const I18nContext = createContext(new I18n('en-US', {...Util.UIStrings, ...UIStrings})); + +function useLhrLocale() { + const flowResult = useFlowResult(); + const firstLhr = flowResult.steps[0].lhr; + const locale = firstLhr.configSettings.locale; + + if (flowResult.steps.some(step => step.lhr.configSettings.locale !== locale)) { + console.warn('LHRs have inconsistent locales'); + } + + return { + locale, + lhrStrings: firstLhr.i18n.rendererFormattedStrings, + }; +} export function useI18n() { - const i18n = useContext(I18nContext); - if (!i18n) throw Error('i18n was not initialized'); - return i18n; + return useContext(I18nContext); } -export function useUIStrings() { +export function useLocalizedStrings() { const i18n = useI18n(); return i18n.strings; } export function useStringFormatter() { - const locale = useLocale(); + const {locale} = useLhrLocale(); return (str: string, values?: Record) => { return formatMessage(str, values, locale); }; } export const I18nProvider: FunctionComponent = ({children}) => { - const locale = useLocale(); - const i18n = useMemo(() => new I18n(locale, { - // Set missing renderer strings to default (english) values. - ...UIStrings, - // `strings` is generated in build/build-report.js - ...strings[locale], - }), [locale]); + const {locale, lhrStrings} = useLhrLocale(); + + const i18n = useMemo(() => { + const i18n = new I18n(locale, { + // Set any missing lhr strings to default (english) values. + ...Util.UIStrings, + // Preload with strings from the first lhr. + // Used for legacy report components imported into the flow report. + ...lhrStrings, + // Set any missing flow strings to default (english) values. + ...UIStrings, + // `strings` is generated in build/build-report.js + ...strings[locale], + }); + + // Initialize renderer util i18n for strings rendered in wrapped components. + // TODO: Don't attach global i18n to `Util`. + // @ts-ignore TS reports as read-only. + Util.i18n = i18n; + + return i18n; + }, [locale, lhrStrings]); return ( diff --git a/flow-report/src/sidebar/sidebar.tsx b/flow-report/src/sidebar/sidebar.tsx index f3050d63d4b7..8ddd5e13b3a6 100644 --- a/flow-report/src/sidebar/sidebar.tsx +++ b/flow-report/src/sidebar/sidebar.tsx @@ -7,14 +7,14 @@ import {FunctionComponent} from 'preact'; import {Separator} from '../common'; -import {useI18n, useUIStrings} from '../i18n/i18n'; +import {useI18n, useLocalizedStrings} from '../i18n/i18n'; import {CpuIcon, EnvIcon, SummaryIcon} from '../icons'; import {classNames, useCurrentLhr, useFlowResult} from '../util'; import {SidebarFlow} from './flow'; export const SidebarSummary: FunctionComponent = () => { const currentLhr = useCurrentLhr(); - const strings = useUIStrings(); + const strings = useLocalizedStrings(); const url = new URL(location.href); url.hash = '#'; @@ -33,19 +33,21 @@ export const SidebarSummary: FunctionComponent = () => { }; const SidebarRuntimeSettings: FunctionComponent<{settings: LH.ConfigSettings}> = ({settings}) => { - const strings = useUIStrings(); + const strings = useLocalizedStrings(); return (
-
+
{ - settings.formFactor === 'desktop' ? strings.desktop : strings.mobile + settings.formFactor === 'desktop' ? + strings.runtimeDesktopEmulation : + strings.runtimeMobileEmulation }
-
+
diff --git a/flow-report/src/summary/category.tsx b/flow-report/src/summary/category.tsx index ec758a9a7a72..6dfbb2593da6 100644 --- a/flow-report/src/summary/category.tsx +++ b/flow-report/src/summary/category.tsx @@ -9,7 +9,7 @@ import {FunctionComponent} from 'preact'; import {Util} from '../../../report/renderer/util'; import {Separator} from '../common'; import {CategoryScore} from '../wrappers/category-score'; -import {useStringFormatter, useUIStrings} from '../i18n/i18n'; +import {useI18n, useStringFormatter, useLocalizedStrings} from '../i18n/i18n'; import type {UIStringsType} from '../i18n/ui-strings'; @@ -34,7 +34,7 @@ export const SummaryTooltip: FunctionComponent<{ category: LH.ReportResult.Category, gatherMode: LH.Result.GatherMode }> = ({category, gatherMode}) => { - const strings = useUIStrings(); + const strings = useLocalizedStrings(); const str_ = useStringFormatter(); const { numPassed, @@ -43,10 +43,12 @@ export const SummaryTooltip: FunctionComponent<{ totalWeight, } = Util.calculateCategoryFraction(category); + const i18n = useI18n(); const displayAsFraction = Util.shouldDisplayAsFraction(gatherMode); - const rating = displayAsFraction ? - Util.calculateRating(numPassed / numPassableAudits) : - Util.calculateRating(category.score); + const score = displayAsFraction ? + numPassed / numPassableAudits : + category.score; + const rating = score === null ? 'error' : Util.calculateRating(score); return (
@@ -61,9 +63,9 @@ export const SummaryTooltip: FunctionComponent<{
{getCategoryRating(rating, strings)} { - !displayAsFraction && category.score && <> + !displayAsFraction && category.score !== null && <> ยท - {category.score * 100} + {i18n.formatNumber(category.score * 100)} }
diff --git a/flow-report/src/summary/summary.tsx b/flow-report/src/summary/summary.tsx index 1d6c9b4fd7a6..8fc51283fa36 100644 --- a/flow-report/src/summary/summary.tsx +++ b/flow-report/src/summary/summary.tsx @@ -11,13 +11,13 @@ import {FlowSegment, FlowStepThumbnail, Separator} from '../common'; import {getModeDescription, useFlowResult} from '../util'; import {Util} from '../../../report/renderer/util'; import {SummaryCategory} from './category'; -import {useStringFormatter, useUIStrings} from '../i18n/i18n'; +import {useStringFormatter, useLocalizedStrings} from '../i18n/i18n'; const DISPLAYED_CATEGORIES = ['performance', 'accessibility', 'best-practices', 'seo']; const THUMBNAIL_WIDTH = 50; const SummaryNavigationHeader: FunctionComponent<{lhr: LH.Result}> = ({lhr}) => { - const strings = useUIStrings(); + const strings = useLocalizedStrings(); return (
@@ -50,7 +50,7 @@ export const SummaryFlowStep: FunctionComponent<{ hashIndex: number, }> = ({lhr, label, hashIndex}) => { const reportResult = useMemo(() => Util.prepareReportResult(lhr), [lhr]); - const strings = useUIStrings(); + const strings = useLocalizedStrings(); const modeDescription = getModeDescription(lhr.gatherMode, strings); return ( @@ -107,7 +107,7 @@ const SummaryFlow: FunctionComponent = () => { export const SummaryHeader: FunctionComponent = () => { const flowResult = useFlowResult(); - const strings = useUIStrings(); + const strings = useLocalizedStrings(); const str_ = useStringFormatter(); let numNavigation = 0; @@ -151,7 +151,7 @@ const SummarySectionHeader: FunctionComponent = ({children}) => { }; export const Summary: FunctionComponent = () => { - const strings = useUIStrings(); + const strings = useLocalizedStrings(); return (
diff --git a/flow-report/src/topbar.tsx b/flow-report/src/topbar.tsx index 4ef27fed6b6b..dc5519a89bdf 100644 --- a/flow-report/src/topbar.tsx +++ b/flow-report/src/topbar.tsx @@ -9,7 +9,7 @@ import {useState} from 'preact/hooks'; import {HelpDialog} from './help-dialog'; import {getFilenamePrefix} from '../../report/generator/file-namer'; -import {useUIStrings} from './i18n/i18n'; +import {useLocalizedStrings} from './i18n/i18n'; import {HamburgerIcon} from './icons'; import {useFlowResult} from './util'; import {useReportRenderer} from './wrappers/report-renderer'; @@ -79,7 +79,7 @@ export const Topbar: FunctionComponent<{onMenuClick: JSX.MouseEventHandler { const flowResult = useFlowResult(); const {dom} = useReportRenderer(); - const strings = useUIStrings(); + const strings = useLocalizedStrings(); const [showHelpDialog, setShowHelpDialog] = useState(false); return ( diff --git a/flow-report/src/util.ts b/flow-report/src/util.ts index fabc740c2794..d46222219fcc 100644 --- a/flow-report/src/util.ts +++ b/flow-report/src/util.ts @@ -79,11 +79,6 @@ export function useFlowResult(): LH.FlowResult { return flowResult; } -export function useLocale(): LH.Locale { - const flowResult = useFlowResult(); - return flowResult.steps[0].lhr.configSettings.locale; -} - export function useHashParam(param: string) { const [paramValue, setParamValue] = useState(getHashParam(param)); diff --git a/flow-report/test/topbar-test.tsx b/flow-report/test/topbar-test.tsx index bac5620b6e7d..87b301412b28 100644 --- a/flow-report/test/topbar-test.tsx +++ b/flow-report/test/topbar-test.tsx @@ -18,6 +18,7 @@ const flowResult = { steps: [{lhr: { fetchTime: '2021-09-14T22:24:22.462Z', configSettings: {locale: 'en-US'}, + i18n: {rendererFormattedStrings: {}}, }}], } as any; diff --git a/flow-report/test/wrappers/category-score-test.tsx b/flow-report/test/wrappers/category-score-test.tsx new file mode 100644 index 000000000000..7087221a216e --- /dev/null +++ b/flow-report/test/wrappers/category-score-test.tsx @@ -0,0 +1,88 @@ +/** + * @license Copyright 2021 The Lighthouse Authors. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +import {FunctionComponent} from 'preact'; +import {render} from '@testing-library/preact'; + +import {CategoryScore} from '../../src/wrappers/category-score'; +import {FlowResultContext} from '../../src/util'; +import {ReportRendererProvider} from '../../src/wrappers/report-renderer'; +import {I18nProvider} from '../../src/i18n/i18n'; +import {flowResult} from '../sample-flow'; + +let wrapper: FunctionComponent; + +beforeEach(() => { + wrapper = ({children}) => ( + + + + {children} + + + + ); +}); + +describe('CategoryScore', () => { + it('renders score gauge', () => { + const category: any = { + id: 'seo', + score: 0.95, + auditRefs: [], + }; + const root = render( + , + {wrapper} + ); + + const link = root.getByRole('link') as HTMLAnchorElement; + + expect(link.href).toEqual('file:///Users/example/report.html/#seo'); + expect(root.getByText('95')).toBeTruthy(); + expect(root.baseElement.querySelector('.lh-gauge__label')).toBeFalsy(); + }); + + it('renders error gauge', () => { + const category: any = { + id: 'seo', + score: null, + auditRefs: [], + }; + const root = render( + , + {wrapper} + ); + + const link = root.getByRole('link') as HTMLAnchorElement; + + expect(link.href).toEqual('file:///Users/example/report.html/#seo'); + expect(root.getByText('?')).toBeTruthy(); + }); + + it('renders category fraction', () => { + const category: any = { + id: 'seo', + auditRefs: [ + {weight: 1, result: {score: 1, scoreDisplayMode: 'binary'}}, + {weight: 1, result: {score: 1, scoreDisplayMode: 'binary'}}, + {weight: 1, result: {score: 0, scoreDisplayMode: 'binary'}}, + {weight: 1, result: {score: 0, scoreDisplayMode: 'binary'}}, + ], + }; + const root = render( + , + {wrapper} + ); + + const link = root.getByRole('link') as HTMLAnchorElement; + + expect(link.href).toEqual('file:///Users/example/report.html/#seo'); + expect(root.getByText('2/4')).toBeTruthy(); + expect(root.baseElement.querySelector('.lh-fraction__label')).toBeFalsy(); + expect(root.baseElement.querySelector('.lh-fraction__background')).toBeFalsy(); + }); +}); diff --git a/lighthouse-core/util-commonjs.js b/lighthouse-core/util-commonjs.js index 1859136ba834..9af382901cef 100644 --- a/lighthouse-core/util-commonjs.js +++ b/lighthouse-core/util-commonjs.js @@ -170,6 +170,8 @@ class Util { /** * Convert a score to a rating label. + * TODO: Return `'error'` for `score === null && !scoreDisplayMode`. + * * @param {number|null} score * @param {string=} scoreDisplayMode * @return {string} diff --git a/report/renderer/util.js b/report/renderer/util.js index 488911b3d283..cb12d1bff35f 100644 --- a/report/renderer/util.js +++ b/report/renderer/util.js @@ -167,6 +167,8 @@ export class Util { /** * Convert a score to a rating label. + * TODO: Return `'error'` for `score === null && !scoreDisplayMode`. + * * @param {number|null} score * @param {string=} scoreDisplayMode * @return {string}