]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19688 Use react-intl for translations
authorJeremy Davis <jeremy.davis@sonarsource.com>
Tue, 8 Aug 2023 16:12:01 +0000 (18:12 +0200)
committersonartech <sonartech@sonarsource.com>
Thu, 10 Aug 2023 20:02:53 +0000 (20:02 +0000)
27 files changed:
server/sonar-web/design-system/src/components/Banner.tsx
server/sonar-web/design-system/src/components/Breadcrumbs.tsx
server/sonar-web/design-system/src/components/DeferredSpinner.tsx
server/sonar-web/design-system/src/components/Dropdown.tsx
server/sonar-web/design-system/src/components/SelectionCard.tsx
server/sonar-web/design-system/src/components/__tests__/Banner-test.tsx
server/sonar-web/design-system/src/components/__tests__/DeferredSpinner-test.tsx
server/sonar-web/design-system/src/components/__tests__/SelectionCard-test.tsx
server/sonar-web/design-system/src/components/input/DatePicker.tsx
server/sonar-web/design-system/src/components/input/DatePickerCustomCalendarNavigation.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/input/DateRangePicker.tsx
server/sonar-web/design-system/src/components/input/InputSearch.tsx
server/sonar-web/design-system/src/components/input/SearchSelect.tsx
server/sonar-web/design-system/src/components/input/__tests__/DatePicker-test.tsx
server/sonar-web/design-system/src/components/input/__tests__/DateRangePicker-test.tsx
server/sonar-web/design-system/src/components/input/__tests__/MultiSelectMenu-test.tsx
server/sonar-web/design-system/src/components/input/__tests__/SearchSelectDropdown-test.tsx
server/sonar-web/design-system/src/components/modal/Modal.tsx
server/sonar-web/design-system/src/components/modal/__tests__/Modal-test.tsx
server/sonar-web/design-system/src/helpers/l10n.ts [deleted file]
server/sonar-web/design-system/src/helpers/testUtils.tsx
server/sonar-web/src/main/js/app/index.ts
server/sonar-web/src/main/js/app/utils/startReactApp.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/CreationDateFacet.tsx
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityDateInput.tsx
server/sonar-web/src/main/js/helpers/l10nBundle.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 90a6cf70576b032dc53e71bf08b5b3e887cc2ed7..61a6975b125f228741627fc1ef4411cc3a45f4e9 100644 (file)
@@ -19,6 +19,7 @@
  */
 import styled from '@emotion/styled';
 import { ReactNode } from 'react';
+import { useIntl } from 'react-intl';
 import tw from 'twin.macro';
 import {
   LAYOUT_BANNER_HEIGHT,
@@ -26,7 +27,6 @@ import {
   themeColor,
   themeContrast,
 } from '../helpers';
-import { translate } from '../helpers/l10n';
 import { ThemeColors } from '../types';
 import { InteractiveIconBase } from './InteractiveIcon';
 import { CloseIcon, FlagErrorIcon, FlagInfoIcon, FlagSuccessIcon, FlagWarningIcon } from './icons';
@@ -69,6 +69,8 @@ function getVariantInfo(variant: Variant) {
 export function Banner({ children, onDismiss, variant }: Props) {
   const variantInfo = getVariantInfo(variant);
 
+  const intl = useIntl();
+
   return (
     <div role="alert" style={{ height: LAYOUT_BANNER_HEIGHT }}>
       <BannerWrapper
@@ -81,7 +83,7 @@ export function Banner({ children, onDismiss, variant }: Props) {
           {onDismiss && (
             <BannerCloseIcon
               Icon={CloseIcon}
-              aria-label={translate('dismiss')}
+              aria-label={intl.formatMessage({ id: 'dismiss' })}
               onClick={onDismiss}
               size="small"
             />
index f367e38965e16c81461040240272bbe891b01845..c4a4c77693ae05865d155a81d41cc0e69a1b6939 100644 (file)
@@ -20,6 +20,7 @@
 import styled from '@emotion/styled';
 import classNames from 'classnames';
 import React from 'react';
+import { useIntl } from 'react-intl';
 import tw from 'twin.macro';
 import {
   LAYOUT_VIEWPORT_MAX_WIDTH_LARGE,
@@ -28,7 +29,6 @@ import {
   themeColor,
   themeContrast,
 } from '../helpers';
-import { translate } from '../helpers/l10n';
 import { useResizeObserver } from '../hooks/useResizeObserver';
 import { Dropdown } from './Dropdown';
 import { InteractiveIcon } from './InteractiveIcon';
@@ -61,6 +61,8 @@ export function Breadcrumbs(props: Props) {
   } = props;
   const [lengthOfChildren, setLengthOfChildren] = React.useState<number[]>([]);
 
+  const intl = useIntl();
+
   const breadcrumbRef = React.useCallback((node: HTMLLIElement, index: number) => {
     setLengthOfChildren((value) => {
       if (value[index] === node.offsetWidth) {
@@ -138,7 +140,7 @@ export function Breadcrumbs(props: Props) {
 
   return (
     <BreadcrumbWrapper
-      aria-label={ariaLabel ?? translate('breadcrumbs')}
+      aria-label={ariaLabel ?? intl.formatMessage({ id: 'breadcrumbs' })}
       className={classNames('js-breadcrumbs', className)}
       ref={innerRef}
     >
@@ -155,7 +157,7 @@ export function Breadcrumbs(props: Props) {
         >
           <InteractiveIcon
             Icon={ChevronDownIcon}
-            aria-label={expandButtonLabel ?? translate('expand_breadcrumb')}
+            aria-label={expandButtonLabel ?? intl.formatMessage({ id: 'expand_breadcrumb' })}
             className="sw-m-1 sw-mr-2"
             size="small"
           />
index b5fcd1067f1ce601bf824a71ae2fcc2a0dd5c8eb..b6179452123ee70e2cec0f5ead1d4c0197c86c99 100644 (file)
@@ -19,9 +19,9 @@
  */
 import { keyframes } from '@emotion/react';
 import styled from '@emotion/styled';
-import React from 'react';
+import React, { DetailedHTMLProps, HTMLAttributes } from 'react';
+import { useIntl } from 'react-intl';
 import tw from 'twin.macro';
-import { translate } from '../helpers/l10n';
 import { themeColor } from '../helpers/theme';
 
 interface Props {
@@ -83,7 +83,8 @@ export class DeferredSpinner extends React.PureComponent<Props, State> {
       if (customSpinner) {
         return customSpinner;
       }
-      return <Spinner aria-label={ariaLabel} className={className} role="status" />;
+      // Overwrite aria-label only if defined
+      return <Spinner {...(ariaLabel ? { 'aria-label': ariaLabel } : {})} className={className} />;
     }
     if (children) {
       return children;
@@ -105,7 +106,8 @@ const spinAnimation = keyframes`
   }
 `;
 
-export const Spinner = styled.div`
+/* Exported to allow styles to be overridden */
+export const StyledSpinner = styled.div`
   border: 2px solid transparent;
   background: linear-gradient(0deg, ${themeColor('primary')} 50%, transparent 50% 100%) border-box,
     linear-gradient(90deg, ${themeColor('primary')} 25%, transparent 75% 100%) border-box;
@@ -120,7 +122,13 @@ export const Spinner = styled.div`
   ${tw`sw-rounded-pill`}
 `;
 
-Spinner.defaultProps = { 'aria-label': translate('loading'), role: 'status' };
+function Spinner(props: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) {
+  const intl = useIntl();
+
+  return (
+    <StyledSpinner aria-label={intl.formatMessage({ id: 'loading' })} role="status" {...props} />
+  );
+}
 
 const Placeholder = styled.div`
   position: relative;
index c7d7b530e3e3c5691ced9b965305e1b52155ce66..6fb33b5853bb0fc8fa708f3213e9d6c3d4f965e8 100644 (file)
@@ -19,7 +19,7 @@
  */
 
 import React from 'react';
-import { translate } from '../helpers/l10n';
+import { useIntl } from 'react-intl';
 import { PopupPlacement, PopupZLevel } from '../helpers/positioning';
 import { InputSizeKeys } from '../types/theme';
 import { DropdownMenu } from './DropdownMenu';
@@ -147,11 +147,14 @@ interface ActionsDropdownProps extends Omit<Props, 'children' | 'overlay'> {
 
 export function ActionsDropdown(props: ActionsDropdownProps) {
   const { children, buttonSize, ariaLabel, ...dropdownProps } = props;
+
+  const intl = useIntl();
+
   return (
     <Dropdown overlay={children} {...dropdownProps}>
       <InteractiveIcon
         Icon={MenuIcon}
-        aria-label={ariaLabel ?? translate('menu')}
+        aria-label={ariaLabel ?? intl.formatMessage({ id: 'menu' })}
         size={buttonSize}
         stopPropagation={false}
       />
index 41c5541a3eac925eb188748ca598669aa56ed1c2..9f14e1df8532a4c2cd6129cf79fb7cb3886d047c 100644 (file)
@@ -19,8 +19,8 @@
  */
 import styled from '@emotion/styled';
 import classNames from 'classnames';
+import { useIntl } from 'react-intl';
 import tw from 'twin.macro';
-import { translate } from '../helpers/l10n';
 import { themeBorder, themeColor, themeContrast, themeShadow } from '../helpers/theme';
 import { LightLabel } from './Text';
 import { RecommendedIcon } from './icons/RecommendedIcon';
@@ -53,6 +53,9 @@ export function SelectionCard(props: SelectionCardProps) {
     vertical = false,
   } = props;
   const isActionable = Boolean(onClick);
+
+  const intl = useIntl();
+
   return (
     <StyledButton
       aria-checked={selected}
@@ -92,7 +95,7 @@ export function SelectionCard(props: SelectionCardProps) {
         <StyledRecommended>
           <StyledRecommendedIcon className="sw-mr-1" />
           <span className="sw-align-middle">
-            <strong>{translate('recommended')}</strong> {recommendedReason}
+            <strong>{intl.formatMessage({ id: 'recommended' })}</strong> {recommendedReason}
           </span>
         </StyledRecommended>
       )}
index f07e4d6daf4b6071adcbae581e84484af4014f04..7f8acf474b560921a362862775723b9073b4411a 100644 (file)
@@ -18,7 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import { screen } from '@testing-library/react';
-import { render } from '../../helpers/testUtils';
+import { renderWithContext } from '../../helpers/testUtils';
 import { FCProps } from '../../types/misc';
 import { Banner } from '../Banner';
 import { Note } from '../Text';
@@ -42,7 +42,7 @@ it('should render with close button', async () => {
 });
 
 function setupWithProps(props: Partial<FCProps<typeof Banner>> = {}) {
-  return render(
+  return renderWithContext(
     <Banner {...props} variant="warning">
       <Note className="sw-body-sm">{props.children ?? 'Test Message'}</Note>
     </Banner>
index 9e5b35753b23fec86a30b9b9b7192c5ee38bc6c2..d8dd6ab7bf00eb5247715ab01ed9759745ab6776 100644 (file)
@@ -18,6 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import { render, screen } from '@testing-library/react';
+import { IntlWrapper } from '../../helpers/testUtils';
 import { DeferredSpinner } from '../DeferredSpinner';
 
 beforeEach(() => {
@@ -65,5 +66,9 @@ function renderDeferredSpinner(props: Partial<DeferredSpinner['props']> = {}) {
 }
 
 function prepareDeferredSpinner(props: Partial<DeferredSpinner['props']> = {}) {
-  return <DeferredSpinner {...props} />;
+  return (
+    <IntlWrapper>
+      <DeferredSpinner {...props} />
+    </IntlWrapper>
+  );
 }
index 36f248ebe9bf4f7db53b1462347da5a86a45addc..9a48c7fbe72d24759333e2dcd5f64845a6ce349b 100644 (file)
@@ -19,7 +19,7 @@
  */
 import { screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
-import { render } from '../../helpers/testUtils';
+import { renderWithContext } from '../../helpers/testUtils';
 import { FCProps } from '../../types/misc';
 import { SelectionCard } from '../SelectionCard';
 
@@ -67,7 +67,7 @@ it('should not be actionnable when no click handler', () => {
 });
 
 function renderSelectionCard(props: Partial<FCProps<typeof SelectionCard>> = {}) {
-  return render(
+  return renderWithContext(
     <SelectionCard
       recommended
       recommendedReason="Recommended for you"
index fc8f1e7c96b937ad09c79c6bf730239ea0c86414..df224a4e4475b559c91fbbb50a752fde7c81363c 100644 (file)
 
 import styled from '@emotion/styled';
 import classNames from 'classnames';
-import {
-  format,
-  getYear,
-  isSameMonth,
-  isSameYear,
-  setMonth,
-  setYear,
-  startOfMonth,
-} from 'date-fns';
-import { range } from 'lodash';
+import { format } from 'date-fns';
 import * as React from 'react';
-import {
-  ActiveModifiers,
-  CaptionProps,
-  Matcher,
-  DayPicker as OriginalDayPicker,
-  useNavigation as useCalendarNavigation,
-  useDayPicker,
-} from 'react-day-picker';
+import { ActiveModifiers, Matcher, DayPicker as OriginalDayPicker } from 'react-day-picker';
 import tw from 'twin.macro';
 import { PopupPlacement, PopupZLevel, themeBorder, themeColor, themeContrast } from '../../helpers';
 import { InputSizeKeys } from '../../types/theme';
@@ -46,20 +30,17 @@ import EscKeydownHandler from '../EscKeydownHandler';
 import { FocusOutHandler } from '../FocusOutHandler';
 import { InteractiveIcon } from '../InteractiveIcon';
 import { OutsideClickHandler } from '../OutsideClickHandler';
-import { CalendarIcon, ChevronLeftIcon, ChevronRightIcon } from '../icons';
+import { CalendarIcon } from '../icons';
 import { CloseIcon } from '../icons/CloseIcon';
 import { Popup } from '../popups';
+import { CustomCalendarNavigation } from './DatePickerCustomCalendarNavigation';
 import { InputField } from './InputField';
-import { InputSelect } from './InputSelect';
 
 // When no minDate is given, year dropdown will show year options up to PAST_MAX_YEARS in the past
 const YEARS_TO_DISPLAY = 10;
-const MONTHS_IN_A_YEAR = 12;
 
 interface Props {
   alignRight?: boolean;
-  ariaNextMonthLabel: string;
-  ariaPreviousMonthLabel: string;
   className?: string;
   clearButtonLabel: string;
   currentMonth?: Date;
@@ -128,8 +109,6 @@ export class DatePicker extends React.PureComponent<Props, State> {
   render() {
     const {
       alignRight,
-      ariaNextMonthLabel,
-      ariaPreviousMonthLabel,
       clearButtonLabel,
       highlightFrom,
       highlightTo,
@@ -181,10 +160,7 @@ export class DatePicker extends React.PureComponent<Props, State> {
                       captionLayout="dropdown-buttons"
                       className="sw-body-sm"
                       components={{
-                        Caption: getCustomCalendarNavigation({
-                          ariaNextMonthLabel,
-                          ariaPreviousMonthLabel,
-                        }),
+                        Caption: CustomCalendarNavigation,
                       }}
                       disabled={{ after: maxDate, before: minDate }}
                       formatters={{
@@ -320,97 +296,3 @@ const DayPicker = styled(OriginalDayPicker)`
     color: ${themeContrast('datePickerSelected')};
   }
 `;
-
-function getCustomCalendarNavigation({
-  ariaNextMonthLabel,
-  ariaPreviousMonthLabel,
-}: {
-  ariaNextMonthLabel: string;
-  ariaPreviousMonthLabel: string;
-}) {
-  return function CalendarNavigation(props: CaptionProps) {
-    const { displayMonth } = props;
-    const { fromYear, toYear } = useDayPicker();
-    const { goToMonth, nextMonth, previousMonth } = useCalendarNavigation();
-
-    const baseDate = startOfMonth(displayMonth); // reference date
-
-    const months = range(MONTHS_IN_A_YEAR).map((month) => {
-      const monthValue = setMonth(baseDate, month);
-
-      return {
-        label: format(monthValue, 'MMM'),
-        value: monthValue,
-      };
-    });
-
-    const startYear = fromYear ?? getYear(Date.now()) - YEARS_TO_DISPLAY;
-
-    const years = range(startYear, toYear ? toYear + 1 : undefined).map((year) => {
-      const yearValue = setYear(baseDate, year);
-
-      return {
-        label: String(year),
-        value: yearValue,
-      };
-    });
-
-    return (
-      <nav className="sw-flex sw-items-center sw-justify-between sw-py-1">
-        <InteractiveIcon
-          Icon={ChevronLeftIcon}
-          aria-label={ariaPreviousMonthLabel}
-          className="sw-mr-2"
-          onClick={() => {
-            if (previousMonth) {
-              goToMonth(previousMonth);
-            }
-          }}
-          size="small"
-        />
-
-        <span data-testid="month-select">
-          <InputSelect
-            isClearable={false}
-            onChange={(value) => {
-              if (value) {
-                goToMonth(value.value);
-              }
-            }}
-            options={months}
-            size="full"
-            value={months.find((m) => isSameMonth(m.value, displayMonth))}
-          />
-        </span>
-
-        <span data-testid="year-select">
-          <InputSelect
-            className="sw-ml-1"
-            data-testid="year-select"
-            isClearable={false}
-            onChange={(value) => {
-              if (value) {
-                goToMonth(value.value);
-              }
-            }}
-            options={years}
-            size="full"
-            value={years.find((y) => isSameYear(y.value, displayMonth))}
-          />
-        </span>
-
-        <InteractiveIcon
-          Icon={ChevronRightIcon}
-          aria-label={ariaNextMonthLabel}
-          className="sw-ml-2"
-          onClick={() => {
-            if (nextMonth) {
-              goToMonth(nextMonth);
-            }
-          }}
-          size="small"
-        />
-      </nav>
-    );
-  };
-}
diff --git a/server/sonar-web/design-system/src/components/input/DatePickerCustomCalendarNavigation.tsx b/server/sonar-web/design-system/src/components/input/DatePickerCustomCalendarNavigation.tsx
new file mode 100644 (file)
index 0000000..6ce279b
--- /dev/null
@@ -0,0 +1,130 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import {
+  format,
+  getYear,
+  isSameMonth,
+  isSameYear,
+  setMonth,
+  setYear,
+  startOfMonth,
+} from 'date-fns';
+import { range } from 'lodash';
+import {
+  CaptionProps,
+  useNavigation as useCalendarNavigation,
+  useDayPicker,
+} from 'react-day-picker';
+import { useIntl } from 'react-intl';
+import { InteractiveIcon } from '../InteractiveIcon';
+import { ChevronLeftIcon, ChevronRightIcon } from '../icons';
+import { InputSelect } from './InputSelect';
+
+const YEARS_TO_DISPLAY = 10;
+const MONTHS_IN_A_YEAR = 12;
+
+export function CustomCalendarNavigation(props: CaptionProps) {
+  const { displayMonth } = props;
+  const { fromYear, toYear } = useDayPicker();
+  const { goToMonth, nextMonth, previousMonth } = useCalendarNavigation();
+
+  const intl = useIntl();
+
+  const baseDate = startOfMonth(displayMonth); // reference date
+
+  const months = range(MONTHS_IN_A_YEAR).map((month) => {
+    const monthValue = setMonth(baseDate, month);
+
+    return {
+      label: format(monthValue, 'MMM'),
+      value: monthValue,
+    };
+  });
+
+  const startYear = fromYear ?? getYear(Date.now()) - YEARS_TO_DISPLAY;
+
+  const years = range(startYear, toYear ? toYear + 1 : undefined).map((year) => {
+    const yearValue = setYear(baseDate, year);
+
+    return {
+      label: String(year),
+      value: yearValue,
+    };
+  });
+
+  return (
+    <nav className="sw-flex sw-items-center sw-justify-between sw-py-1">
+      <InteractiveIcon
+        Icon={ChevronLeftIcon}
+        aria-label={intl.formatMessage({ id: 'previous_' })}
+        className="sw-mr-2"
+        onClick={() => {
+          if (previousMonth) {
+            goToMonth(previousMonth);
+          }
+        }}
+        size="small"
+      />
+
+      <span data-testid="month-select">
+        <InputSelect
+          isClearable={false}
+          onChange={(value) => {
+            if (value) {
+              goToMonth(value.value);
+            }
+          }}
+          options={months}
+          size="full"
+          value={months.find((m) => isSameMonth(m.value, displayMonth))}
+        />
+      </span>
+
+      <span data-testid="year-select">
+        <InputSelect
+          className="sw-ml-1"
+          data-testid="year-select"
+          isClearable={false}
+          onChange={(value) => {
+            if (value) {
+              goToMonth(value.value);
+            }
+          }}
+          options={years}
+          size="full"
+          value={years.find((y) => isSameYear(y.value, displayMonth))}
+        />
+      </span>
+
+      <InteractiveIcon
+        Icon={ChevronRightIcon}
+        aria-label={intl.formatMessage({ id: 'next_' })}
+        className="sw-ml-2"
+        onClick={() => {
+          if (nextMonth) {
+            goToMonth(nextMonth);
+          }
+        }}
+        size="small"
+      />
+    </nav>
+  );
+}
index f3f65ea29c10a042ddead71143ca51efa70fc133..393946732ea2dfc857d3c472a5653755af2e576f 100644 (file)
@@ -31,8 +31,6 @@ interface DateRange {
 
 interface Props {
   alignEndDateCalandarRight?: boolean;
-  ariaNextMonthLabel: string;
-  ariaPreviousMonthLabel: string;
   className?: string;
   clearButtonLabel: string;
   fromLabel: string;
@@ -75,8 +73,6 @@ export class DateRangePicker extends React.PureComponent<Props> {
   render() {
     const {
       alignEndDateCalandarRight,
-      ariaNextMonthLabel,
-      ariaPreviousMonthLabel,
       clearButtonLabel,
       fromLabel,
       minDate,
@@ -90,8 +86,6 @@ export class DateRangePicker extends React.PureComponent<Props> {
     return (
       <div className={classNames('sw-flex sw-items-center', this.props.className)}>
         <DatePicker
-          ariaNextMonthLabel={ariaNextMonthLabel}
-          ariaPreviousMonthLabel={ariaPreviousMonthLabel}
           clearButtonLabel={clearButtonLabel}
           currentMonth={this.to}
           data-test="from"
@@ -109,8 +103,6 @@ export class DateRangePicker extends React.PureComponent<Props> {
         <LightLabel className="sw-mx-2">{separatorText ?? '–'}</LightLabel>
         <DatePicker
           alignRight={alignEndDateCalandarRight}
-          ariaNextMonthLabel={ariaNextMonthLabel}
-          ariaPreviousMonthLabel={ariaPreviousMonthLabel}
           clearButtonLabel={clearButtonLabel}
           currentMonth={this.from}
           data-test="to"
index c6d14f4639cd103f3042c06e279994121c3e4864..25428652814bbf8624499213729b0dd4071ea269 100644 (file)
@@ -28,7 +28,7 @@ import { Key } from '../../helpers/keyboard';
 import { themeBorder, themeColor, themeContrast } from '../../helpers/theme';
 import { isDefined } from '../../helpers/types';
 import { InputSizeKeys } from '../../types/theme';
-import { DeferredSpinner, Spinner } from '../DeferredSpinner';
+import { DeferredSpinner, StyledSpinner } from '../DeferredSpinner';
 import { InteractiveIcon } from '../InteractiveIcon';
 import { CloseIcon } from '../icons/CloseIcon';
 import { SearchIcon } from '../icons/SearchIcon';
@@ -193,7 +193,7 @@ export const InputSearchWrapper = styled.div`
   ${tw`sw-align-middle`}
   ${tw`sw-h-control`}
 
-  ${Spinner} {
+  ${StyledSpinner} {
     top: calc((2.25rem - ${theme('spacing.4')}) / 2);
     ${tw`sw-left-3`};
     ${tw`sw-absolute`};
index 79b654a70fa027a8469bd0ccf5179fc9faa72e6d..91c14aa81da7a98e7ae5f5cc3e50e1ea9bb170f5 100644 (file)
 import classNames from 'classnames';
 import { omit } from 'lodash';
 import React, { RefObject } from 'react';
+import { useIntl } from 'react-intl';
 import { GroupBase, InputProps, components } from 'react-select';
 import AsyncSelect, { AsyncProps } from 'react-select/async';
 import Select from 'react-select/dist/declarations/src/Select';
 import { INPUT_SIZES } from '../../helpers';
 import { Key } from '../../helpers/keyboard';
-import { translate } from '../../helpers/l10n';
 import { InputSearch } from './InputSearch';
 import { LabelValueSelectOption, SelectProps, selectStyle } from './InputSelect';
 
@@ -92,6 +92,8 @@ export function SearchSelectInput<
     selectProps: { clearIconLabel, placeholder, isLoading, inputValue, minLength, tooShortText },
   } = props;
 
+  const intl = useIntl();
+
   const onChange = (v: string, prevValue = '') => {
     props.selectProps.onInputChange(v, { action: 'input-change', prevInputValue: prevValue });
   };
@@ -107,7 +109,7 @@ export function SearchSelectInput<
 
   return (
     <InputSearch
-      clearIconAriaLabel={clearIconLabel ?? translate('clear')}
+      clearIconAriaLabel={clearIconLabel ?? intl.formatMessage({ id: 'clear' })}
       loading={isLoading && inputValue.length >= (minLength ?? 0)}
       minLength={minLength}
       onChange={onChange}
index 81d9f167caf49515db1c3679e8dc9fc54ccb52ca..dd68da9b97551a23433ba03e66a732eb60288599 100644 (file)
@@ -21,7 +21,7 @@
 import { screen, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { getMonth, getYear, parseISO } from 'date-fns';
-import { render } from '../../../helpers/testUtils';
+import { renderWithContext } from '../../../helpers/testUtils';
 import { DatePicker } from '../DatePicker';
 
 it('behaves correctly', async () => {
@@ -40,7 +40,7 @@ it('behaves correctly', async () => {
   const nav = screen.getByRole('navigation');
   expect(nav).toBeInTheDocument();
 
-  await user.click(within(nav).getByRole('button', { name: 'previous' }));
+  await user.click(within(nav).getByRole('button', { name: 'previous_' }));
   await user.click(screen.getByText('7'));
 
   expect(onChange).toHaveBeenCalled();
@@ -54,9 +54,7 @@ it('behaves correctly', async () => {
    * Then check that onChange was correctly called with a date in the following month
    */
   await user.click(screen.getByRole('textbox'));
-  const nextButton = screen.getByRole('button', { name: 'next' });
-  await user.click(nextButton);
-  await user.click(nextButton);
+  await user.click(screen.getByRole('button', { name: 'next_' }));
   await user.click(screen.getByText('12'));
 
   expect(onChange).toHaveBeenCalled();
@@ -131,10 +129,8 @@ it.each([
 function renderDatePicker(overrides: Partial<DatePicker['props']> = {}) {
   const defaultFormatter = (date?: Date) => (date ? date.toISOString() : '');
 
-  render(
+  renderWithContext(
     <DatePicker
-      ariaNextMonthLabel="next"
-      ariaPreviousMonthLabel="previous"
       clearButtonLabel="clear"
       onChange={jest.fn()}
       placeholder="placeholder"
index 62902dfcb1c570b55d11491b83cd4d9fbbee4f8a..b4147d7ae30154fcefa9baf1cb8d32945f92b052 100644 (file)
@@ -20,7 +20,7 @@
 import { screen, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { formatISO, parseISO } from 'date-fns';
-import { render } from '../../../helpers/testUtils';
+import { IntlWrapper, render } from '../../../helpers/testUtils';
 import { DateRangePicker } from '../DateRangePicker';
 
 beforeEach(() => {
@@ -79,15 +79,15 @@ function renderDateRangePicker(overrides: Partial<DateRangePicker['props']> = {}
     date ? formatISO(date, { representation: 'date' }) : '';
 
   render(
-    <DateRangePicker
-      ariaNextMonthLabel="next"
-      ariaPreviousMonthLabel="previous"
-      clearButtonLabel="clear"
-      fromLabel="from"
-      onChange={jest.fn()}
-      toLabel="to"
-      valueFormatter={defaultFormatter}
-      {...overrides}
-    />
+    <IntlWrapper messages={{ next_: 'next', previous_: 'previous' }}>
+      <DateRangePicker
+        clearButtonLabel="clear"
+        fromLabel="from"
+        onChange={jest.fn()}
+        toLabel="to"
+        valueFormatter={defaultFormatter}
+        {...overrides}
+      />
+    </IntlWrapper>
   );
 }
index a1c7b5800ea8727d571e974912a76c7d58d9933e..7035ce401f256951fe09c0a5fcf2d9aa841e5fea 100644 (file)
@@ -17,8 +17,9 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { render, screen } from '@testing-library/react';
+import { screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
+import { renderWithContext } from '../../../helpers/testUtils';
 import { MultiSelectMenu } from '../MultiSelectMenu';
 
 const elements = ['foo', 'bar', 'baz'];
@@ -94,7 +95,7 @@ it('should show no results', () => {
 });
 
 function renderMultiselect(props: Partial<MultiSelectMenu['props']> = {}) {
-  return render(
+  return renderWithContext(
     <MultiSelectMenu
       clearIconAriaLabel="clear"
       createElementLabel="create thing"
index 1c7d487413654fd5488325a1cd20841ff222c9a6..a48cb517340e09bf35fab70f5f007f57e445bee3 100644 (file)
@@ -19,7 +19,7 @@
  */
 import { act, screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
-import { render } from '../../../helpers/testUtils';
+import { renderWithContext } from '../../../helpers/testUtils';
 import { FCProps } from '../../../types/misc';
 import { LabelValueSelectOption } from '../InputSelect';
 import { SearchSelectDropdown } from '../SearchSelectDropdown';
@@ -85,7 +85,7 @@ it('behaves correctly in disabled state', async () => {
 });
 
 function renderSearchSelectDropdown(props: Partial<FCProps<typeof SearchSelectDropdown>> = {}) {
-  return render(
+  return renderWithContext(
     <SearchSelectDropdown
       aria-label="label"
       controlLabel="not assigned"
index c2e1ce8ed6a0aab4cb17e2e0c9a0cecbaac48f2f..01a9163178ddbf58361843f0b0bf0a5e9bec3cf3 100644 (file)
 import { Global, css, useTheme } from '@emotion/react';
 import classNames from 'classnames';
 import { ReactNode } from 'react';
+import { useIntl } from 'react-intl';
 import ReactModal from 'react-modal';
 import tw from 'twin.macro';
 import { themeColor } from '../../helpers';
 import { REACT_DOM_CONTAINER } from '../../helpers/constants';
-import { translate } from '../../helpers/l10n';
 import { Theme } from '../../types/theme';
 import { ButtonSecondary } from '../buttons';
 import { ModalBody } from './ModalBody';
@@ -84,7 +84,7 @@ export function Modal({
   ...props
 }: Props) {
   const theme = useTheme();
-
+  const intl = useIntl();
   return (
     <>
       <Global styles={globalStyles({ theme })} />
@@ -118,7 +118,7 @@ export function Modal({
                   onClick={onClose}
                   type="reset"
                 >
-                  {props.secondaryButtonLabel ?? translate('close')}
+                  {props.secondaryButtonLabel ?? intl.formatMessage({ id: 'close' })}
                 </ButtonSecondary>
               }
             />
index 86085f547dd9550144108e165a406d1932a062ce..185f69f006bacbb872f816605402056805848c51 100644 (file)
@@ -19,7 +19,7 @@
  */
 
 import { screen } from '@testing-library/react';
-import { render } from '../../../helpers/testUtils';
+import { renderWithContext } from '../../../helpers/testUtils';
 import { Modal, PropsWithChildren, PropsWithSections } from '../Modal';
 
 it('should render default modal with predefined content', async () => {
@@ -61,7 +61,7 @@ it('should request close when pressing esc on loose content', async () => {
 });
 
 function setupPredefinedContent(props: Partial<PropsWithSections> = {}) {
-  return render(
+  return renderWithContext(
     <Modal
       body="Body"
       headerTitle="Hello"
@@ -73,7 +73,7 @@ function setupPredefinedContent(props: Partial<PropsWithSections> = {}) {
 }
 
 function setupLooseContent(props: Partial<PropsWithChildren> = {}, children = <div />) {
-  return render(
+  return renderWithContext(
     <Modal onClose={jest.fn()} {...props}>
       {children}
     </Modal>
@@ -81,7 +81,7 @@ function setupLooseContent(props: Partial<PropsWithChildren> = {}, children = <d
 }
 
 function setupLooseContentWithMultipleChildren(props: Partial<PropsWithChildren> = {}) {
-  return render(
+  return renderWithContext(
     <Modal onClose={jest.fn()} {...props}>
       <div>Hello there!</div>
       <div>How are you?</div>
diff --git a/server/sonar-web/design-system/src/helpers/l10n.ts b/server/sonar-web/design-system/src/helpers/l10n.ts
deleted file mode 100644 (file)
index 3505a14..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-
-/**
- * (!) Do not use this, it is left for legacy purposes and should be slowly replaced with react-intl
- */
-export function translate(keys: string): string {
-  return keys;
-}
-
-export function translateWithParameters(
-  messageKey: string,
-  ...parameters: Array<string | number>
-): string {
-  return `${messageKey}.${parameters.join('.')}`;
-}
index 516c427ba51da09946479de3a1878b5947448946..da50b78db85a93bc151e1723cbdf079b66598829 100644 (file)
@@ -24,7 +24,7 @@ import { InitialEntry } from 'history';
 import { identity, kebabCase } from 'lodash';
 import React, { PropsWithChildren, ReactNode } from 'react';
 import { HelmetProvider } from 'react-helmet-async';
-import { IntlProvider } from 'react-intl';
+import { IntlProvider, ReactIntlErrorCode } from 'react-intl';
 import { MemoryRouter, Route, Routes } from 'react-router-dom';
 
 export function render(
@@ -60,12 +60,14 @@ export function renderWithRouter(
   function RouterWrapper({ children }: React.PropsWithChildren<object>) {
     return (
       <HelmetProvider>
-        <MemoryRouter>
-          <Routes>
-            <Route element={children} path="/" />
-            {additionalRoutes}
-          </Routes>
-        </MemoryRouter>
+        <IntlWrapper>
+          <MemoryRouter>
+            <Routes>
+              <Route element={children} path="/" />
+              {additionalRoutes}
+            </Routes>
+          </MemoryRouter>
+        </IntlWrapper>
       </HelmetProvider>
     );
   }
@@ -77,9 +79,7 @@ function getContextWrapper() {
   return function ContextWrapper({ children }: React.PropsWithChildren<object>) {
     return (
       <HelmetProvider>
-        <IntlProvider defaultLocale="en" locale="en">
-          {children}
-        </IntlProvider>
+        <IntlWrapper>{children}</IntlWrapper>
       </HelmetProvider>
     );
   };
@@ -113,3 +113,28 @@ export const debounceTimer = jest
 
     return debounced;
   });
+
+export function IntlWrapper({
+  children,
+  messages = {},
+}: {
+  children: ReactNode;
+  messages?: Record<string, string>;
+}) {
+  return (
+    <IntlProvider
+      defaultLocale="en"
+      locale="en"
+      messages={messages}
+      onError={(e) => {
+        // ignore missing translations, there are none!
+        if (e.code !== ReactIntlErrorCode.MISSING_TRANSLATION) {
+          // eslint-disable-next-line no-console
+          console.error(e);
+        }
+      }}
+    >
+      {children}
+    </IntlProvider>
+  );
+}
index 81e528d133ab2852a23e9b41dceda23b38d79bd9..8dfc13fcf50f2944751304257869e641ae048925 100644 (file)
@@ -48,7 +48,7 @@ async function initApplication() {
   });
 
   const startReactApp = await import('./utils/startReactApp').then((i) => i.default);
-  startReactApp(l10nBundle.locale, currentUser, appState, availableFeatures);
+  startReactApp(l10nBundle, currentUser, appState, availableFeatures);
 }
 
 function isMainApp() {
index def7cb998a24003415158ac3ad687f04181ff1f2..370f86af3fa723e9b8c1e78f9109d2ae2ae9220b 100644 (file)
@@ -23,7 +23,7 @@ import { lightTheme } from 'design-system';
 import * as React from 'react';
 import { render } from 'react-dom';
 import { Helmet, HelmetProvider } from 'react-helmet-async';
-import { IntlProvider } from 'react-intl';
+import { IntlShape, RawIntlProvider } from 'react-intl';
 import { BrowserRouter, Route, Routes } from 'react-router-dom';
 import accountRoutes from '../../apps/account/routes';
 import auditLogsRoutes from '../../apps/audit-logs/routes';
@@ -177,7 +177,7 @@ function renderRedirects() {
 const queryClient = new QueryClient();
 
 export default function startReactApp(
-  lang: string,
+  l10nBundle: IntlShape,
   currentUser?: CurrentUser,
   appState?: AppState,
   availableFeatures?: Feature[]
@@ -191,7 +191,7 @@ export default function startReactApp(
       <AppStateContextProvider appState={appState ?? DEFAULT_APP_STATE}>
         <AvailableFeaturesContext.Provider value={availableFeatures ?? DEFAULT_AVAILABLE_FEATURES}>
           <CurrentUserContextProvider currentUser={currentUser}>
-            <IntlProvider defaultLocale={lang} locale={lang}>
+            <RawIntlProvider value={l10nBundle}>
               <ThemeProvider theme={lightTheme}>
                 <QueryClientProvider client={queryClient}>
                   <GlobalMessagesContainer />
@@ -266,7 +266,7 @@ export default function startReactApp(
                   </BrowserRouter>
                 </QueryClientProvider>
               </ThemeProvider>
-            </IntlProvider>
+            </RawIntlProvider>
           </CurrentUserContextProvider>
         </AvailableFeaturesContext.Provider>
       </AppStateContextProvider>
index 39730c1831eea018fb634f6c31c96835164c1cbc..e585b8fb015219a6b641536f4ea426ca1d825965 100644 (file)
@@ -177,8 +177,6 @@ export class CreationDateFacetClass extends React.PureComponent<Props & WrappedC
 
     return (
       <DateRangePicker
-        ariaNextMonthLabel={translate('next_')}
-        ariaPreviousMonthLabel={translate('previous_')}
         clearButtonLabel={translate('clear')}
         fromLabel={translate('start_date')}
         onChange={this.handlePeriodChange}
index f7345c123d5fb3c2cbf81a801a9d73b2cfb85265..5d69d727fc9de6ca7c5954d953bfe065ed1cbe47 100644 (file)
@@ -41,8 +41,6 @@ export default class ProjectActivityDateInput extends React.PureComponent<Props>
     return (
       <div className="sw-flex">
         <DateRangePicker
-          ariaNextMonthLabel={translate('next_')}
-          ariaPreviousMonthLabel={translate('previous_')}
           className="sw-w-abs-350"
           clearButtonLabel={translate('clear')}
           fromLabel={translate('start_date')}
index 13a42a9e2faf62fa16b758917a75a2431df731c3..53aa06d8d2c13605080025b5fd5753e58279d878 100644 (file)
@@ -17,6 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+import { IntlShape, createIntl, createIntlCache } from 'react-intl';
 import { fetchL10nBundle } from '../api/l10n';
 import { L10nBundle, L10nBundleRequestParams } from '../types/l10nBundle';
 import { Dict } from '../types/types';
@@ -28,6 +29,12 @@ const DEFAULT_MESSAGES: Dict<string> = {
   default_error_message: 'The request cannot be processed. Try again later.',
 };
 
+let intl: IntlShape;
+
+export function getIntl() {
+  return intl;
+}
+
 export function getMessages() {
   return getL10nBundleFromCache().messages ?? DEFAULT_MESSAGES;
 }
@@ -77,7 +84,17 @@ export async function loadL10nBundle() {
 
   persistL10nBundleInCache(bundle);
 
-  return bundle;
+  const cache = createIntlCache();
+
+  intl = createIntl(
+    {
+      locale: effectiveLocale,
+      messages,
+    },
+    cache
+  );
+
+  return intl;
 }
 
 function getPreferredLanguage() {
index 6dd26e61136a56a3982e7158d5e4f8434e15ed38..4272c60e14893ce17081e6a80eafdc9dea1a3bb0 100644 (file)
@@ -31,6 +31,7 @@ blocker=Blocker
 bold=Bold
 branch=Branch
 breadcrumbs=Breadcrumbs
+expand_breadcrumbs=Expand breadcrumbs
 by_=by
 calendar=Calendar
 cancel=Cancel
@@ -124,6 +125,7 @@ max=Max
 max_results_reached=Only the first {0} results are displayed
 me=Me
 members=Members
+menu=Menu
 min=Min
 minor=Minor
 more=More