diff --git a/src/core/link/Link.stories.tsx b/src/core/link/Link.stories.tsx index ae21196b..020690eb 100644 --- a/src/core/link/Link.stories.tsx +++ b/src/core/link/Link.stories.tsx @@ -44,11 +44,13 @@ const Template: StoryFn = (args) => ( href="https://hel.fi" > External link with an image - City of Helsinki logo + + City of Helsinki logo + , @@ -47,7 +47,7 @@ export function Link({ const isPhone = href?.startsWith('tel:') || undefined; const isExternal = getIsHrefExternal(href) && !isEmail && !isPhone; const iconSize = size === 'S' ? 'xs' : 's'; - const hasImageInLink = getChildrenByType(children, ['img']).length > 0; + const hasImageInLink = findAllElementsOfType(children, ['img']).length > 0; // The external links should always open in a new tab. const openInNewTab = forceOpenInNewTab ?? isExternal; // If the link contains an image, the external icon should be hidden by default, diff --git a/src/core/link/__tests__/Link.test.tsx b/src/core/link/__tests__/Link.test.tsx index 4901011b..47692bda 100644 --- a/src/core/link/__tests__/Link.test.tsx +++ b/src/core/link/__tests__/Link.test.tsx @@ -98,3 +98,15 @@ test('renders internal link with specified screen reader text', () => { screen.getByRole('link', { name: 'This is for screen reader' }), ).toBeInTheDocument(); }); + +test('the link with image should not show an external icon by default', () => { + const { container } = render( + + Text for an external link with image + this is an img inside an external link + , + ); + expect(container.childElementCount).toBe(1); + expect(container.lastChild.nodeName).not.toEqual('svg'); + expect(container).toMatchSnapshot(); +}); diff --git a/src/core/link/__tests__/__snapshots__/Link.test.tsx.snap b/src/core/link/__tests__/__snapshots__/Link.test.tsx.snap new file mode 100644 index 00000000..4f77e477 --- /dev/null +++ b/src/core/link/__tests__/__snapshots__/Link.test.tsx.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`the link with image should not show an external icon by default 1`] = ` +
+ + + Text for an external link with image + this is an img inside an external link + + +
+`; diff --git a/src/core/utils/__tests__/__snapshots__/findAllElementsOfType.test.tsx.snap b/src/core/utils/__tests__/__snapshots__/findAllElementsOfType.test.tsx.snap new file mode 100644 index 00000000..ea7c91e8 --- /dev/null +++ b/src/core/utils/__tests__/__snapshots__/findAllElementsOfType.test.tsx.snap @@ -0,0 +1,80 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`findAllElementsOfType finds the elements of searched types # 1`] = ` +[ +
  • + Should be included 1/2 +
  • , +
  • + Should be included 2/2 +
  • , +] +`; + +exports[`findAllElementsOfType finds the elements of searched types # 2`] = ` +[ +
  • + Should be included 1/3 +
  • , +
  • + Should be included 2/3 +
  • , +
  • + Should be included 3/3 +
  • , +] +`; + +exports[`findAllElementsOfType finds the elements of searched types # 3`] = ` +[ +
  • + Should be included 1/3 +
  • , + , +
  • + Should be included 2/3 +
  • , +
  • + Should be included 3/3 +
  • , +
      +
    1. + Should be included 2/3 +
    2. +
    3. + Should be included 3/3 +
    4. +
    , +] +`; + +exports[`findAllElementsOfType finds the elements of searched types # 4`] = ` +[ +
  • + Should be included 1/3 +
  • , +
  • + Should be included 2/3 +
  • , +
  • + Should be included 3/3 +
  • , +] +`; + +exports[`findAllElementsOfType finds the elements of searched types # 5`] = ` +[ + should be included 1/2, + should be included 2/2, +] +`; diff --git a/src/core/utils/__tests__/__snapshots__/getChildrenByType.test.tsx.snap b/src/core/utils/__tests__/__snapshots__/getChildrenByType.test.tsx.snap new file mode 100644 index 00000000..f2d2931b --- /dev/null +++ b/src/core/utils/__tests__/__snapshots__/getChildrenByType.test.tsx.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getChildrenByType filters childrens by defined list of types 1`] = ` +[ +

    + should contain 1/3 +

    , + should contain 2/3, +

    + should contain 3/3 +

    , +] +`; + +exports[`getChildrenByType filters childrens by defined list of types 2`] = ` +[ + , + , +] +`; diff --git a/src/core/utils/__tests__/__snapshots__/recursiveMap.test.tsx.snap b/src/core/utils/__tests__/__snapshots__/recursiveMap.test.tsx.snap new file mode 100644 index 00000000..eb912fff --- /dev/null +++ b/src/core/utils/__tests__/__snapshots__/recursiveMap.test.tsx.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`recursiveMap can be used with return values to collect content # 1`] = ` +[ +
  • + Should be included 1/3 +
  • , +
  • + Should be included 2/3 +
  • , +
  • + Should be included 3/3 +
  • , +] +`; + +exports[`recursiveMap can be used with return values to collect content # 2`] = ` +[ +

    + 1/3 +

    , +

    + 2/3 +

    , +

    + 3/3 +

    , +] +`; diff --git a/src/core/utils/__tests__/findAllElementsOfType.test.tsx b/src/core/utils/__tests__/findAllElementsOfType.test.tsx new file mode 100644 index 00000000..09b0dbf2 --- /dev/null +++ b/src/core/utils/__tests__/findAllElementsOfType.test.tsx @@ -0,0 +1,61 @@ +import React from 'react'; + +import { findAllElementsOfType } from '../findAllElementsOfType'; + +describe('findAllElementsOfType', () => { + const flattenedListItems = ( + + ); + + const nestedListItems = ( +
    +
      +
    • Should be included 1/3
    • +
    +
      +
    1. Should be included 2/3
    2. +
    3. Should be included 3/3
    4. +
    +
    + ); + + it.each([ + [flattenedListItems, ['li'], 2], // 2 x
  • + [nestedListItems, ['li'], 3], // 3 x
  • + [nestedListItems, ['ul', 'ol', 'li'], 5], //
      ,
        , 3 x
      1. + [ +
        +
        {nestedListItems}
        +
        , + ['li'], + 3, // 3 x
      2. + ], + [ +
        +
        {nestedListItems}
        +
        + + should be included 1/2 + +
        + should be included 2/2 +
        +

        Not included

        +
        +
        , + ['img'], + 2, // 2 x + ], + ])( + 'finds the elements of searched types #', + (component, types, foundCount) => { + const { children } = component.props; + const result = findAllElementsOfType(children, types); + expect(result).toHaveLength(foundCount); + expect(result).toMatchSnapshot(); + }, + ); +}); diff --git a/src/core/utils/__tests__/getChildrenByType.test.tsx b/src/core/utils/__tests__/getChildrenByType.test.tsx new file mode 100644 index 00000000..6275960e --- /dev/null +++ b/src/core/utils/__tests__/getChildrenByType.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import { getChildrenByType } from '../getChildrenByType'; + +describe('getChildrenByType', () => { + it.each([ + [ + ['img', 'p'], + <> +

        should contain 1/3

        + should contain 2/3 + should not contain +

        should contain 3/3

        + , + 3, + ], + [ + ['ul'], + <> +
          +
        1. Should not contain
        2. +
        +
          +
        • Should contain 1/2
        • +
        +

        Should not contain

        +
        +
          +
        • Should contain 2/2
        • +
        + , + 2, + ], + ])( + 'filters childrens by defined list of types', + (types, component, filteredCount) => { + const { children } = component.props; + const result = getChildrenByType(children, types); + expect(result).toHaveLength(filteredCount); + expect(result).toMatchSnapshot(); + }, + ); +}); diff --git a/src/core/utils/__tests__/recursiveMap.test.tsx b/src/core/utils/__tests__/recursiveMap.test.tsx new file mode 100644 index 00000000..c1e83f2e --- /dev/null +++ b/src/core/utils/__tests__/recursiveMap.test.tsx @@ -0,0 +1,105 @@ +import React from 'react'; + +import { recursiveMap } from '../recursiveMap'; +import { typeOfComponent } from '../getChildrenByType'; + +describe('recursiveMap', () => { + const flattenedListItems = ( +
          +
        • Should be included 1/2
        • +
        • Should be included 2/2
        • +
        + ); + + const nestedListItems = ( +
        +
          +
        • Should be included 1/3
        • +
        +
          +
        1. Should be included 2/3
        2. +
        3. Should be included 3/3
        4. +
        +
        + ); + + it.each([ + [flattenedListItems, jest.fn(), 2], // 2 x
      3. + [nestedListItems, jest.fn(), 1 + 1 + 3], //
          ,
            and 3 x
          1. + [ +
            +
            {nestedListItems}
            +
            , + jest.fn(), + 1 + 1 + 1 + 1 + 3, //
            , sub
            ,
              ,
                and 3 x
              1. + ], + ])('recursively calls the defined function', (component, fn, callCount) => { + const { children } = component.props; + recursiveMap(children, fn); + expect(fn).toHaveBeenCalledTimes(callCount); + }); + + it('can be used with void functions e.g. to count elements', () => { + const { children } = nestedListItems.props; + const elementCounts = { + li: 3, + ul: 1, + ol: 1, + }; + const elementCounter: Record = { + li: 0, + ul: 0, + ol: 0, + }; + const fn = (child: React.ReactElement) => { + switch (typeOfComponent(child)) { + case 'li': + elementCounter.li += 1; + break; + case 'ul': + elementCounter.ul += 1; + break; + case 'ol': + elementCounter.ol += 1; + break; + default: + break; + } + return child; + }; + recursiveMap(children, fn); + expect(elementCounter).toStrictEqual(elementCounts); + }); + + it.each([ + [nestedListItems, 'li', 3], + [ +
                +

                1/3

                +
                +

                2/3

                +
                +
                +
                +

                3/3

                +
                +
                +
                , + 'p', + 3, + ], + ])( + 'can be used with return values to collect content #', + (component, type, elementCount) => { + const { children } = component.props; + const result = []; + const fn = (child: React.ReactElement) => { + if (typeOfComponent(child) === type) result.push(child); + return child; + }; + recursiveMap(children, fn); + expect(result).toHaveLength(elementCount); + expect(result).toMatchSnapshot(); + }, + ); +}); diff --git a/src/core/utils/findAllElementsOfType.ts b/src/core/utils/findAllElementsOfType.ts new file mode 100644 index 00000000..7fe9a924 --- /dev/null +++ b/src/core/utils/findAllElementsOfType.ts @@ -0,0 +1,18 @@ +import { getChildrenByType } from './getChildrenByType'; +import { recursiveMap } from './recursiveMap'; + +/** + * Find all elements of defined types recursively from a ReactNode. + * @returns a list of elements of specified types that could be found from inside the ReactNode. + */ +export const findAllElementsOfType = ( + children: React.ReactNode, + types: string[], +) => { + const elementsOfType = []; + recursiveMap(children, (child) => { + elementsOfType.push(getChildrenByType(child, types)); + return child; + }); + return elementsOfType.flat(); +}; diff --git a/src/core/utils/getChildrenByType.ts b/src/core/utils/getChildrenByType.ts index 4c327652..27b95e56 100644 --- a/src/core/utils/getChildrenByType.ts +++ b/src/core/utils/getChildrenByType.ts @@ -4,11 +4,8 @@ import React from 'react'; * Gets the string type of the component or core html (JSX) element. * React Fragments will return type 'react.fragment'. * Priority will be given to the prop '__TYPE'. - * - * @param {ReactNode} component - The component to type check - * @returns {string} - The string representation of the type */ -export const typeOfComponent = (component) => +export const typeOfComponent = (component: React.ReactElement): string => // eslint-disable-next-line no-underscore-dangle component?.props?.__TYPE || component?.type @@ -22,14 +19,16 @@ export const typeOfComponent = (component) => * and then the 'type' string to match core html elements. * To find a React Fragment, search for type 'react.fragment'. * - * @param {ReactNode} children - JSX children - * @param {string[]} types - Types of children to match - * @returns {ReactNode[]} - Array of matching children * @example * // Finds all occurrences of ToDo (custom component), div, and React Fragment * getChildrenByType(children, ['ToDo', 'div', 'react.fragment']); */ -export const getChildrenByType = (children: React.ReactNode, types: string[]) => +export const getChildrenByType = ( + children: React.ReactNode, + types: string[], +): React.ReactNode[] => React.Children.toArray(children).filter( - (child) => types.indexOf(typeOfComponent(child)) !== -1, + (child) => + React.isValidElement(child) && + types.indexOf(typeOfComponent(child)) !== -1, ); diff --git a/src/core/utils/recursiveMap.ts b/src/core/utils/recursiveMap.ts new file mode 100644 index 00000000..df20874b --- /dev/null +++ b/src/core/utils/recursiveMap.ts @@ -0,0 +1,23 @@ +import React from 'react'; + +export const recursiveMap = ( + children: React.ReactNode, + fn: (children: React.ReactNode) => React.ReactNode, +) => + React.Children.map(children, (child) => { + if (!React.isValidElement(child)) { + // Strings inside HTML elements, etc. + return child; + } + + if (child.props.children) { + // Make a recursive call to find the last node + return fn( + React.cloneElement(child as React.ReactElement, { + children: recursiveMap(child.props.children, fn), + }), + ); + } + // Should never(?) come here + return fn(child); + })?.flat();