Skip to content
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
7 changes: 7 additions & 0 deletions .changeset/fresh-towns-marry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@youversion/platform-react-hooks': patch
'@youversion/platform-core': patch
'@youversion/platform-react-ui': patch
---

fix(ui): show specific Bible passage error messages
2 changes: 1 addition & 1 deletion packages/ui/src/components/bible-card.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export const Error: Story = {
await waitFor(async () => {
await expect(canvas.getByRole('heading', { level: 2, name: /error/i })).toBeInTheDocument();
const errorMessages = canvas.getAllByText(
'Your previously selected Bible verse is unavailable.',
'The Bible service is having trouble right now. Please try again in a moment.',
);
await expect(errorMessages.length).toBeGreaterThan(0);
});
Expand Down
185 changes: 184 additions & 1 deletion packages/ui/src/components/verse.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* @vitest-environment jsdom
*/
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Verse, BibleTextView, type BibleTextViewPassageState } from './verse';
Expand Down Expand Up @@ -694,3 +694,186 @@ describe('BibleTextView - Refetch loading behavior', () => {
});
});
});

describe('BibleTextView - Error messaging', () => {
const originalNavigator = globalThis.navigator;

function createError(message: string, status?: number): Error {
return Object.assign(new Error(message), status === undefined ? {} : { status });
}

afterEach(() => {
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: originalNavigator,
});
});

it('should show a passage-specific message for 404 errors', async () => {
const { getByRole } = render(
<BibleTextView
reference="PRO.30.1"
versionId={2530}
passageState={{
passage: null,
loading: false,
error: createError('Bible passage PRO.30.1 for version 2530 not found', 404),
}}
/>,
);

await waitFor(() => {
expect(getByRole('alert')).toHaveTextContent(
"This passage isn't available in the selected Bible version.",
);
});
});

it('should show an app key message for 401 errors', async () => {
const { getByRole } = render(
<BibleTextView
reference="JHN.3.16"
versionId={3034}
passageState={{
passage: null,
loading: false,
error: createError('Request failed with status 401', 401),
}}
/>,
);

await waitFor(() => {
expect(getByRole('alert')).toHaveTextContent(
"This Bible content couldn't be loaded because the app key is missing or invalid.",
);
});
});

it('should show a forbidden message for 403 errors', async () => {
const { getByRole } = render(
<BibleTextView
reference="JHN.3.16"
versionId={3034}
passageState={{
passage: null,
loading: false,
error: createError('Request failed with status 403', 403),
}}
/>,
);

await waitFor(() => {
expect(getByRole('alert')).toHaveTextContent(
"This app isn't allowed to access this Bible content.",
);
});
});

it('should show an offline message when navigator reports offline', async () => {
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: {
...originalNavigator,
onLine: false,
},
});

const { getByRole } = render(
<BibleTextView
reference="JHN.3.16"
versionId={3034}
passageState={{
passage: null,
loading: false,
error: createError('Unexpected connection state'),
}}
/>,
);

await waitFor(() => {
expect(getByRole('alert')).toHaveTextContent(
"The Bible server couldn't be reached. Check your connection and try again.",
);
});
});

it('should show an unreachable-server message for request timeouts', async () => {
const { getByRole } = render(
<BibleTextView
reference="JHN.3.16"
versionId={3034}
passageState={{
passage: null,
loading: false,
error: createError('Request timeout after 10000ms'),
}}
/>,
);

await waitFor(() => {
expect(getByRole('alert')).toHaveTextContent(
"The Bible server couldn't be reached. Check your connection and try again.",
);
});
});

it('should show a rate-limit message for 429 errors', async () => {
const { getByRole } = render(
<BibleTextView
reference="JHN.3.16"
versionId={3034}
passageState={{
passage: null,
loading: false,
error: createError('Request failed with status 429', 429),
}}
/>,
);

await waitFor(() => {
expect(getByRole('alert')).toHaveTextContent(
'The Bible service is receiving too many requests right now. Please wait a moment and try again.',
);
});
});

it('should show a service message for 5xx errors', async () => {
const { getByRole } = render(
<BibleTextView
reference="JHN.3.16"
versionId={3034}
passageState={{
passage: null,
loading: false,
error: createError('Request failed with status 503', 503),
}}
/>,
);

await waitFor(() => {
expect(getByRole('alert')).toHaveTextContent(
'The Bible service is having trouble right now. Please try again in a moment.',
);
});
});

it('should prioritize 5xx errors over "not found" text in the message', async () => {
const { getByRole } = render(
<BibleTextView
reference="JHN.3.16"
versionId={3034}
passageState={{
passage: null,
loading: false,
error: createError('Upstream dependency not found while handling request', 503),
}}
/>,
);

await waitFor(() => {
expect(getByRole('alert')).toHaveTextContent(
'The Bible service is having trouble right now. Please try again in a moment.',
);
});
});
});
11 changes: 4 additions & 7 deletions packages/ui/src/components/verse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { ExclamationCircle } from '@/components/icons/exclamation-circle';
import { Footnote } from '@/components/icons/footnote';
import { LoaderIcon } from '@/components/icons/loader';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { getBibleTextErrorMessage } from '@/lib/bible-text-error';
import { cn } from '@/lib/utils';
import { type FontFamily } from '@/lib/verse-html-utils';
import { transformBibleHtml } from '@youversion/platform-core/browser';
Expand Down Expand Up @@ -142,23 +143,19 @@ const VerseFootnoteButton = memo(function VerseFootnoteButton({
);
});

const VERSE_UNAVAILABLE_MESSAGE = 'Your previously selected Bible verse is unavailable.';

/**
* Displays a verse-unavailable error message with a circular exclamation
* icon and descriptive text.
*/
function VerseUnavailableMessage(): React.ReactElement {
function VerseUnavailableMessage({ message }: { message: string }): React.ReactElement {
return (
<div
role="alert"
aria-live="polite"
className="yv:flex yv:items-center yv:justify-center yv:gap-2.5 yv:px-3 yv:py-2.5 yv:text-foreground"
>
<ExclamationCircle className="yv:size-5 yv:shrink-0 yv:text-foreground" />
<p className="yv:m-0 yv:text-[13px] yv:font-medium yv:leading-tight">
{VERSE_UNAVAILABLE_MESSAGE}
</p>
<p className="yv:m-0 yv:text-[13px] yv:font-medium yv:leading-tight">{message}</p>
</div>
);
}
Expand Down Expand Up @@ -470,7 +467,7 @@ export const BibleTextView = forwardRef<HTMLDivElement, BibleTextViewProps>(
if (currentError) {
return (
<div ref={ref} data-yv-sdk data-yv-theme={currentTheme}>
<VerseUnavailableMessage />
<VerseUnavailableMessage message={getBibleTextErrorMessage(currentError)} />
</div>
);
}
Expand Down
60 changes: 60 additions & 0 deletions packages/ui/src/lib/bible-text-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
type BibleTextError = Error & {
status?: number;
};

const PASSAGE_NOT_FOUND_MESSAGE = "This passage isn't available in the selected Bible version.";
const INVALID_APP_KEY_MESSAGE =
"This Bible content couldn't be loaded because the app key is missing or invalid.";
const FORBIDDEN_MESSAGE = "This app isn't allowed to access this Bible content.";
const UNREACHABLE_SERVER_MESSAGE =
"The Bible server couldn't be reached. Check your connection and try again.";
const RATE_LIMITED_MESSAGE =
'The Bible service is receiving too many requests right now. Please wait a moment and try again.';
const SERVER_ERROR_MESSAGE =
'The Bible service is having trouble right now. Please try again in a moment.';
const GENERIC_ERROR_MESSAGE = "We couldn't load this Bible passage. Please try again.";

function isUnreachableServerError(message: string): boolean {
return (
message.includes('failed to fetch') ||
message.includes('networkerror') ||
message.includes('network request failed') ||
message.includes('load failed') ||
message.includes('request timeout')
);
}

export function getBibleTextErrorMessage(error: BibleTextError): string {
const status = error.status;
const message = error.message.toLowerCase();

if (status === 401) {
return INVALID_APP_KEY_MESSAGE;
}

if (status === 403) {
return FORBIDDEN_MESSAGE;
}

if (status === 429) {
return RATE_LIMITED_MESSAGE;
}

if (status !== undefined && status >= 500) {
return SERVER_ERROR_MESSAGE;
}

if (status === 404 || message.includes('not found')) {
return PASSAGE_NOT_FOUND_MESSAGE;
}

if (typeof navigator !== 'undefined' && navigator.onLine === false) {
return UNREACHABLE_SERVER_MESSAGE;
}

if (isUnreachableServerError(message)) {
return UNREACHABLE_SERVER_MESSAGE;
}

return GENERIC_ERROR_MESSAGE;
}
Loading