Skip to content

Commit eefecb1

Browse files
committed
Fix BL-15820 Addition to TC error message
For various reasons simply adding ""Important: synchronization problems can be caused when one or more members of your team have incorrect Dropbox settings. Please ensure all members of your team collection are using the correct settings. See [critical Dropbox settings](https://docs.bloomlibrary.org/critical-dropbox-settings/)."; lead to all these changes!
1 parent 3174c16 commit eefecb1

4 files changed

Lines changed: 430 additions & 215 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2+
import * as React from "react";
3+
import * as ReactDOM from "react-dom";
4+
import { renderToStaticMarkup } from "react-dom/server";
5+
import { act } from "react-dom/test-utils";
6+
7+
vi.mock("../utils/bloomApi", () => ({
8+
post: vi.fn(),
9+
postString: vi.fn(),
10+
}));
11+
12+
import { post, postString } from "../utils/bloomApi";
13+
import { StringWithOptionalLink } from "./stringWithOptionalLink";
14+
15+
const postMock = vi.mocked(post);
16+
const postStringMock = vi.mocked(postString);
17+
18+
describe("StringWithOptionalLink", () => {
19+
let container: HTMLDivElement | null = null;
20+
21+
const renderIntoDom = (message: string) => {
22+
if (!container) {
23+
throw new Error("render container not initialized");
24+
}
25+
26+
const target = container;
27+
act(() => {
28+
ReactDOM.render(
29+
<StringWithOptionalLink message={message} />,
30+
target,
31+
);
32+
});
33+
return target;
34+
};
35+
36+
beforeEach(() => {
37+
container = document.createElement("div");
38+
document.body.appendChild(container);
39+
vi.clearAllMocks();
40+
});
41+
42+
afterEach(() => {
43+
if (container) {
44+
ReactDOM.unmountComponentAtNode(container);
45+
container.remove();
46+
container = null;
47+
}
48+
vi.clearAllMocks();
49+
});
50+
51+
it("renders spans and anchors for multiple links", () => {
52+
const markup = renderToStaticMarkup(
53+
<StringWithOptionalLink
54+
message={
55+
"Start <a href='/bloom/api/internal'>first</a> middle <a href='http://example.com'>second</a> end"
56+
}
57+
/>,
58+
);
59+
const temp = document.createElement("div");
60+
temp.innerHTML = markup;
61+
62+
const spans = temp.querySelectorAll("span");
63+
const anchors = temp.querySelectorAll("a");
64+
65+
expect(spans.length).toBe(3);
66+
expect(spans[0].textContent).toBe("Start ");
67+
expect(spans[1].textContent).toBe(" middle ");
68+
expect(spans[2].textContent).toBe(" end");
69+
70+
expect(anchors.length).toBe(2);
71+
expect(anchors[0].textContent).toBe("first");
72+
expect(anchors[0].getAttribute("href")).toBe("/bloom/api/internal");
73+
expect(anchors[1].textContent).toBe("second");
74+
expect(anchors[1].getAttribute("href")).toBe("http://example.com");
75+
});
76+
77+
it("invokes post for internal links", () => {
78+
const host = renderIntoDom(
79+
"Do <a href='/bloom/api/doThing'>this</a> now",
80+
);
81+
const anchor = host.querySelector("a");
82+
expect(anchor).not.toBeNull();
83+
84+
anchor?.dispatchEvent(
85+
new MouseEvent("click", { bubbles: true, cancelable: true }),
86+
);
87+
88+
expect(postMock).toHaveBeenCalledWith("doThing");
89+
expect(postStringMock).not.toHaveBeenCalled();
90+
});
91+
92+
it("invokes postString for external links", () => {
93+
const host = renderIntoDom(
94+
"Visit <a href='mailto:test@example.com'>email</a>",
95+
);
96+
const anchor = host.querySelector("a");
97+
expect(anchor).not.toBeNull();
98+
99+
anchor?.dispatchEvent(
100+
new MouseEvent("click", { bubbles: true, cancelable: true }),
101+
);
102+
103+
expect(postStringMock).toHaveBeenCalledWith(
104+
"link",
105+
"mailto:test@example.com",
106+
);
107+
expect(postMock).not.toHaveBeenCalled();
108+
});
109+
});
Lines changed: 53 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,66 @@
11
import * as React from "react";
2-
import { post } from "../utils/bloomApi";
2+
import { post, postString } from "../utils/bloomApi";
33

44
// Display a string which may have embedded a link, in the usual HTML format,
55
// for example, "There is a problem. Please click <a href='...'>here</a> to report it".
66
// (Currently the href must use single quotes.)
77
// If there is no link, the result is a single span, the message.
8-
// If there is a link, we get a span, a link, and another span.
8+
// If there are links, we interleave spans and anchor tags for each segment.
99
// Currently the href is assumed to be something to send to our API,
1010
// but NOT to actually navigate to. We could support more options as needed.
1111
export const StringWithOptionalLink: React.FunctionComponent<{
1212
message: string;
1313
}> = (props) => {
14-
const match = props.message.match(
15-
/^(.*?)<a[^>]*?href='([^>']+)'[^>]*>(.*?)<\/a>(.*)$/,
16-
);
17-
if (match) {
18-
const href = match[2].replace("/bloom/api/", "");
19-
return (
20-
<React.Fragment>
21-
<span>{match[1]}</span>
22-
<a
23-
// We don't currently use the href, but to get link formatting it
24-
// has to be present. May also be helpful for accessibility.
25-
href={match[2]}
26-
onClick={(e) => {
27-
e.preventDefault(); // so it doesn't try to follow the link
28-
post(href);
29-
}}
30-
>
31-
{match[3]}
32-
</a>
33-
<span>{match[4]}</span>
34-
</React.Fragment>
14+
const linkRegex = /<a[^>]*?href='([^>']+)'[^>]*>(.*?)<\/a>/g;
15+
const elements: React.ReactNode[] = [];
16+
let lastIndex = 0;
17+
let segmentIndex = 0;
18+
let match: RegExpExecArray | null;
19+
20+
while ((match = linkRegex.exec(props.message))) {
21+
const precedingText = props.message.slice(lastIndex, match.index);
22+
if (precedingText) {
23+
elements.push(
24+
<span key={`text-${segmentIndex}`}>{precedingText}</span>,
25+
);
26+
segmentIndex++;
27+
}
28+
29+
const rawHref = match[1];
30+
const isExternalLink =
31+
rawHref.startsWith("http") || rawHref.startsWith("mailto");
32+
const href = rawHref.replace("/bloom/api/", "");
33+
34+
elements.push(
35+
<a
36+
key={`link-${segmentIndex}`}
37+
// We don't currently use the href, but to get link formatting it
38+
// has to be present. May also be helpful for accessibility.
39+
href={rawHref}
40+
onClick={(e) => {
41+
e.preventDefault(); // so it doesn't try to follow the link
42+
if (isExternalLink) {
43+
postString("link", rawHref);
44+
return;
45+
}
46+
post(href);
47+
}}
48+
>
49+
{match[2]}
50+
</a>,
3551
);
36-
} else return <span>{props.message}</span>;
52+
segmentIndex++;
53+
lastIndex = linkRegex.lastIndex;
54+
}
55+
56+
const trailingText = props.message.slice(lastIndex);
57+
if (trailingText) {
58+
elements.push(<span key={`text-${segmentIndex}`}>{trailingText}</span>);
59+
}
60+
61+
if (elements.length === 0) {
62+
return <span>{props.message}</span>;
63+
}
64+
65+
return <React.Fragment>{elements}</React.Fragment>;
3766
};

0 commit comments

Comments
 (0)