Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [IOCOM-2096] New lines on messages' IOMarkdown, when a br tag is encountered #6783

Merged
merged 5 commits into from
Mar 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ts/components/IOMarkdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Body } from "@pagopa/io-app-design-system";
import * as Sentry from "@sentry/react-native";
import { useIOSelector } from "../../store/hooks";
import { isScreenReaderEnabledSelector } from "../../store/reducers/preferences";
import I18n from "../../i18n";
import { IOMarkdownRenderRules } from "./types";
import {
getRenderMarkdown,
Expand Down Expand Up @@ -56,7 +57,7 @@ const IOMarkdown = ({ content, rules, onError }: Props) => (
<Sentry.ErrorBoundary
fallback={
<View>
<Body>{"C'è stato un problema con il caricamento del contenuto"}</Body>
<Body>{I18n.t("global.markdown.decodeError")}</Body>
</View>
}
onError={onError}
Expand Down
113 changes: 77 additions & 36 deletions ts/components/IOMarkdown/renderRules.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
H6,
HSpacer,
IOPictogramsBleed,
IOSpacer,
IOStyles,
IOToast,
Nullable,
Expand All @@ -35,7 +36,13 @@ import {
TxtStrNode,
TxtStrongNode
} from "@textlint/ast-node-types";
import { Fragment, useLayoutEffect, useState } from "react";
import {
ExoticComponent,
Fragment,
ReactNode,
useLayoutEffect,
useState
} from "react";
import { Dimensions, Image, Pressable, Text, View } from "react-native";
import I18n from "../../i18n";
import { openWebUrl } from "../../utils/url";
Expand Down Expand Up @@ -159,25 +166,13 @@ export const DEFAULT_RULES: IOMarkdownRenderRules = {
render: Renderer,
screenReaderEnabled: boolean
) {
const Heading = HEADINGS_MAP[header.depth];

const allLinkData = extractAllLinksFromRootNode(
return headerNodeToReactNative(
header,
HEADINGS_MAP,
handleOpenLink,
render,
screenReaderEnabled
);
const nodeKey = getTxtNodeKey(header);

return (
<Fragment key={nodeKey}>
<Heading>{header.children.map(render)}</Heading>
{generateAccesibilityLinkViewsIfNeeded(
allLinkData,
nodeKey,
handleOpenLink,
screenReaderEnabled
)}
</Fragment>
);
},
/**
* @param paragraph The `Paragraph` node.
Expand Down Expand Up @@ -248,24 +243,15 @@ export const DEFAULT_RULES: IOMarkdownRenderRules = {
* @returns The rendered component.
*/
Str(str: TxtStrNode) {
return <Fragment key={getTxtNodeKey(str)}>{str.value}</Fragment>;
return strNodeToReactNative(str.value, str);
},
/**
* @param link The `Link` node.
* @param render The renderer function.
* @returns The rendered component.
*/
Link(link: TxtLinkNode, render: Renderer) {
return (
<Body
weight="Semibold"
asLink
key={getTxtNodeKey(link)}
onPress={() => handleOpenLink(link.url)}
>
{link.children.map(render)}
</Body>
);
return linkNodeToReactNative(link, () => handleOpenLink(link.url), render);
},
/**
* @param image The `Image` node.
Expand Down Expand Up @@ -444,14 +430,7 @@ export const DEFAULT_RULES: IOMarkdownRenderRules = {
const [, value] = val;

if (value === "br") {
const hasAParentParagraphNode = isParagraphNodeInHierarchy(html.parent);
return hasAParentParagraphNode ? (
<Fragment key={getTxtNodeKey(html)}>{"\n"}</Fragment>
) : (
<Body key={getTxtNodeKey(html)}>
<Fragment>{"\n"}</Fragment>
</Body>
);
htmlNodeToReactNative("\n", html, html.parent);
}

return null;
Expand All @@ -464,3 +443,65 @@ export const DEFAULT_RULES: IOMarkdownRenderRules = {
<Divider key={getTxtNodeKey(horizontalRule)} />
)
};

export const headerNodeToReactNative = (
header: TxtHeaderNode,
headingsMap: Record<
number,
ExoticComponent<{ children?: ReactNode | undefined }>
>,
onPress: (url: string) => void,
render: Renderer,
screenReaderEnabled: boolean,
marginStart: IOSpacer | undefined = undefined,
marginEnd: IOSpacer | undefined = undefined
) => {
const Heading = headingsMap[header.depth];

const allLinkData = extractAllLinksFromRootNode(header, screenReaderEnabled);
const nodeKey = getTxtNodeKey(header);

return (
<Fragment key={nodeKey}>
{marginStart != null && <VSpacer size={marginStart} />}
<Heading>{header.children.map(render)}</Heading>
{marginEnd != null && <VSpacer size={marginEnd} />}
{generateAccesibilityLinkViewsIfNeeded(
allLinkData,
nodeKey,
onPress,
screenReaderEnabled
)}
</Fragment>
);
};

export const htmlNodeToReactNative = (
content: string,
node: AnyTxtNode,
parent?: TxtParentNode
) => {
const hasAParentParagraphNode = isParagraphNodeInHierarchy(parent);
const nodeKey = getTxtNodeKey(node);
return hasAParentParagraphNode ? (
<Fragment key={nodeKey}>{content}</Fragment>
) : (
<Body key={nodeKey}>
<Fragment>{content}</Fragment>
</Body>
);
};

export const linkNodeToReactNative = (
link: TxtLinkNode,
onPress: () => void,
render: Renderer
) => (
<Body weight="Semibold" asLink key={getTxtNodeKey(link)} onPress={onPress}>
{link.children.map(render)}
</Body>
);

export const strNodeToReactNative = (content: string, node: AnyTxtNode) => (
<Fragment key={getTxtNodeKey(node)}>{content}</Fragment>
);
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IOToast } from "@pagopa/io-app-design-system";
import { Body, IOToast, MdH1, MdH2, MdH3 } from "@pagopa/io-app-design-system";
import * as URL from "../../../../../utils/url";
import { testable } from "../customRules";
import I18n from "../../../../../i18n";
Expand All @@ -7,6 +7,37 @@ describe("customRules", () => {
afterEach(() => {
jest.restoreAllMocks();
});
describe("DEFAULT_HEADING_MARGINS", () => {
it("should match default values", () => {
const defaultHeadingMargins = testable!.DEFAULT_HEADING_MARGINS;
expect(defaultHeadingMargins).toEqual({
marginStart: 8,
marginEnd: 4
});
});
});
describe("HEADINGS_MAP", () => {
it("should match default values", () => {
const headersMap = testable!.HEADINGS_MAP;
expect(headersMap).toEqual({
1: MdH1,
2: MdH2,
3: MdH3,
4: Body,
5: Body,
6: Body
});
});
});
describe("SPACER_VALUES", () => {
it("should match default values", () => {
const spacerValues = testable!.SPACER_VALUES;
expect(spacerValues).toEqual({
1: { marginStart: 16, marginEnd: 4 },
2: { marginStart: 16, marginEnd: 8 }
});
});
});
describe("handleOpenLink", () => {
const linkToMock = jest.fn();
it("should call input function for ioit:// protocol", () => {
Expand Down Expand Up @@ -54,4 +85,33 @@ describe("customRules", () => {
});
});
});
describe("replaceBrWithNewline", () => {
[
["Hello<br>World", "Hello\nWorld"],
["Hello<br/>World", "Hello\nWorld"],
["Hello<br />World", "Hello\nWorld"],
["Hello<br />World", "Hello\nWorld"],
["Hello<br >World", "Hello\nWorld"],
["Line1<br>Line2<br>Line3", "Line1\nLine2\nLine3"],
["Line1<br/>Line2<br/>Line3", "Line1\nLine2\nLine3"],
["Line1<br />Line2<br />Line3", "Line1\nLine2\nLine3"],
["Line1<br />Line2<br />Line3", "Line1\nLine2\nLine3"],
["Mix<br>of<br/><br />types", "Mix\nof\n\ntypes"],
["Uppercase<BR>test", "Uppercase\ntest"],
["Mixedcase<bR>test", "Mixedcase\ntest"],
["Nothing to replace", "Nothing to replace"],
["", ""],
["<br>Only BR", "\nOnly BR"],
["<br>", "\n"],
["<br/>", "\n"],
["<br />", "\n"],
["<br />", "\n"],
["Weird<brstyle='color:red;'>case", "Weird<brstyle='color:red;'>case"]
].forEach(testCase => {
it(`should replace 'br' tag with newline character for input ${testCase[0]}`, () => {
const output = testable!.replaceBrWithNewline(testCase[0]);
expect(output).toBe(testCase[1]);
});
});
});
});
105 changes: 50 additions & 55 deletions ts/features/common/components/IOMarkdown/customRules.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,19 @@ import {
IOToast,
MdH1,
MdH2,
MdH3,
VSpacer
MdH3
} from "@pagopa/io-app-design-system";
import {
TxtHeaderNode,
TxtHtmlNode,
TxtLinkNode
TxtLinkNode,
TxtStrNode
} from "@textlint/ast-node-types";
import { Fragment } from "react";
import { extractAllLinksFromRootNode } from "../../../../components/IOMarkdown/markdownRenderer";
import {
generateAccesibilityLinkViewsIfNeeded,
getTxtNodeKey
headerNodeToReactNative,
htmlNodeToReactNative,
linkNodeToReactNative,
strNodeToReactNative
} from "../../../../components/IOMarkdown/renderRules";
import {
IOMarkdownRenderRules,
Expand Down Expand Up @@ -45,6 +45,18 @@ const HEADINGS_MAP = {
6: Body
};

const SPACER_VALUES: {
[key: number]: HeadingMargins;
} = {
1: { marginStart: 16, marginEnd: 4 },
2: { marginStart: 16, marginEnd: 8 }
};

const DEFAULT_HEADING_MARGINS: HeadingMargins = {
marginStart: 8,
marginEnd: 4
};

const handleOpenLink = (linkTo: (path: string) => void, url: string) => {
if (isIoInternalLink(url)) {
handleInternalLink(linkTo, url);
Expand All @@ -65,61 +77,44 @@ export const generateMessagesAndServicesRules = (
render: Renderer,
screenReaderEnabled: boolean
) {
const Heading = HEADINGS_MAP[header.depth];

const spacerValues: {
[key: number]: HeadingMargins;
} = {
1: { marginStart: 16, marginEnd: 4 },
2: { marginStart: 16, marginEnd: 8 }
};

const defaultHeadingMargins: HeadingMargins = {
marginStart: 8,
marginEnd: 4
};

const { marginStart, marginEnd } =
spacerValues[header.depth] || defaultHeadingMargins;

const allLinkData = extractAllLinksFromRootNode(
SPACER_VALUES[header.depth] || DEFAULT_HEADING_MARGINS;
return headerNodeToReactNative(
header,
screenReaderEnabled
);
const nodeKey = getTxtNodeKey(header);

return (
<Fragment key={nodeKey}>
<VSpacer size={marginStart} />
<Heading>{header.children.map(render)}</Heading>
<VSpacer size={marginEnd} />
{generateAccesibilityLinkViewsIfNeeded(
allLinkData,
nodeKey,
(url: string) => handleOpenLink(linkTo, url),
screenReaderEnabled
)}
</Fragment>
HEADINGS_MAP,
(url: string) => handleOpenLink(linkTo, url),
render,
screenReaderEnabled,
marginStart,
marginEnd
);
},
Link(link: TxtLinkNode, render: Renderer) {
return (
<Body
weight="Semibold"
asLink
key={getTxtNodeKey(link)}
onPress={() => handleOpenLink(linkTo, link.url)}
>
{link.children.map(render)}
</Body>
return linkNodeToReactNative(
link,
() => handleOpenLink(linkTo, link.url),
render
);
},
Html: (_html: TxtHtmlNode) => undefined
Html: (html: TxtHtmlNode) => {
const backwardCompatibleValue = replaceBrWithNewline(html.value);
return htmlNodeToReactNative(backwardCompatibleValue, html, html.parent);
},
Str(str: TxtStrNode) {
const backwardCompatibleValue = replaceBrWithNewline(str.value);
return strNodeToReactNative(backwardCompatibleValue, str);
}
});

export const generatePreconditionsRules =
(): Partial<IOMarkdownRenderRules> => ({
Html: (_html: TxtHtmlNode) => undefined
});
const replaceBrWithNewline = (input: string): string =>
input.replace(/<br\s*\/?>/gi, "\n");

export const testable = isTestEnv ? { handleOpenLink } : undefined;
export const testable = isTestEnv
? {
DEFAULT_HEADING_MARGINS,
HEADINGS_MAP,
SPACER_VALUES,
handleOpenLink,
replaceBrWithNewline
}
: undefined;
Loading
Loading