Skip to content
Closed
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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@
"@types/testing-library__react": "10.2.0",
"@types/url-parse": "^1.4.8",
"@typescript-eslint/eslint-plugin": "5.62.0",
"@typescript-eslint/parser": "8.52.0",
"@typescript-eslint/parser": "8.58.0",
"amphtml-validator": "1.0.38",
"babel-jest": "30.3.0",
"babel-loader": "10.1.1",
Expand Down
39 changes: 39 additions & 0 deletions src/app/components/SaveArticleButton/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ describe('SaveArticleButton', () => {
const defaultProps = {
articleId: '123',
service: 'hindi',
title: 'Test Article Title',
};

const mockHandleSaveAction = jest.fn();

afterEach(() => {
jest.clearAllMocks();
});
Expand All @@ -21,6 +24,7 @@ describe('SaveArticleButton', () => {
showButton: false,
isSaved: false,
isLoading: false,
handleSaveAction: mockHandleSaveAction,
});

const { container } = render(<SaveArticleButton {...defaultProps} />);
Expand All @@ -32,6 +36,7 @@ describe('SaveArticleButton', () => {
showButton: true,
isSaved: false,
isLoading: false,
handleSaveAction: mockHandleSaveAction,
});

render(<SaveArticleButton {...defaultProps} />);
Expand All @@ -43,6 +48,7 @@ describe('SaveArticleButton', () => {
showButton: true,
isSaved: true,
isLoading: false,
handleSaveAction: mockHandleSaveAction,
});

render(<SaveArticleButton {...defaultProps} />);
Expand All @@ -54,6 +60,7 @@ describe('SaveArticleButton', () => {
showButton: true,
isSaved: false,
isLoading: true,
handleSaveAction: mockHandleSaveAction,
});

render(<SaveArticleButton {...defaultProps} />);
Expand All @@ -62,4 +69,36 @@ describe('SaveArticleButton', () => {
expect(button).toHaveTextContent('Loading...');
expect(button).toBeDisabled();
});

test('calls handleSaveAction with save when button is clicked and not saved', async () => {
mockedUseUASButton.mockReturnValue({
showButton: true,
isSaved: false,
isLoading: false,
handleSaveAction: mockHandleSaveAction,
});

render(<SaveArticleButton {...defaultProps} />);
screen.getByRole('button').click();

expect(mockHandleSaveAction).toHaveBeenCalledWith('save');
expect(mockHandleSaveAction).toHaveBeenCalledTimes(1);
});

test('passes title to useUASButton hook', () => {
mockedUseUASButton.mockReturnValue({
showButton: true,
isSaved: false,
isLoading: false,
handleSaveAction: mockHandleSaveAction,
});

render(<SaveArticleButton {...defaultProps} />);

expect(mockedUseUASButton).toHaveBeenCalledWith({
articleId: '123',
service: 'hindi',
title: 'Test Article Title',
});
});
});
19 changes: 14 additions & 5 deletions src/app/components/SaveArticleButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,25 @@ import styles from './index.styles';
interface SaveArticleButtonProps {
articleId: string;
service: string;
title: string;
}

/** A button component that allows users to save an article for later reading,
* showing the button based on user sign in status and feature toggles,
* and displaying the saved status, loading state, and handling errors from the UAS API.
* FUTURE TODO : Implement button click handler to toggle saved state */

const SaveArticleButton = ({ articleId, service }: SaveArticleButtonProps) => {
const { showButton, isSaved, isLoading, error } = useUASButton({
articleId,
service,
});
const SaveArticleButton = ({
articleId,
service,
title,
}: SaveArticleButtonProps) => {
const { showButton, isSaved, isLoading, error, handleSaveAction } =
useUASButton({
articleId,
service,
title,
});

if (!showButton) {
return null;
Expand Down Expand Up @@ -43,6 +51,7 @@ const SaveArticleButton = ({ articleId, service }: SaveArticleButtonProps) => {
<button
css={styles.buttonWrapper}
type="button"
onClick={() => handleSaveAction(isSaved ? 'remove' : 'save')}
disabled={isLoading}
aria-label={buttonLabel}
title={buttonLabel}
Expand Down
87 changes: 86 additions & 1 deletion src/app/hooks/useUASButton/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { use } from 'react';
import { renderHook } from '#app/components/react-testing-library-with-providers';
import {
renderHook,
act,
} from '#app/components/react-testing-library-with-providers';
import useUASFetchSaveStatus from '#app/hooks/useUASFetchSaveStatus';
import isLocal from '#app/lib/utilities/isLocal';
import uasApiRequest from '#app/lib/uasApi';
import useUASButton from './index';

import useToggle from '../useToggle';

jest.mock('#app/hooks/useUASFetchSaveStatus');
jest.mock('../useToggle');
jest.mock('#app/lib/utilities/isLocal');
jest.mock('#app/lib/uasApi');
jest.mock('react', () => ({
...jest.requireActual('react'),
use: jest.fn(),
Expand All @@ -17,19 +22,25 @@ jest.mock('react', () => ({
const mockuseUASFetchSaveStatus = useUASFetchSaveStatus as jest.Mock;
const mockUseToggle = useToggle as jest.Mock;
const mockIsLocal = isLocal as jest.Mock;
const mockUasApiRequest = uasApiRequest as jest.Mock;

describe('useUASButton', () => {
const defaultProps = {
articleId: '123',
service: 'hindi',
title: 'Test Article',
};

const mockSetIsSaved = jest.fn();

beforeEach(() => {
jest.clearAllMocks();

mockuseUASFetchSaveStatus.mockReturnValue({
isSaved: false,
isLoading: false,
error: null,
setIsSaved: mockSetIsSaved,
});

(use as jest.Mock).mockReturnValue({
Expand Down Expand Up @@ -70,6 +81,7 @@ describe('useUASButton', () => {
isSaved: true,
isLoading: false,
error: null,
setIsSaved: mockSetIsSaved,
});

const { result } = renderHook(() => useUASButton(defaultProps));
Expand Down Expand Up @@ -122,4 +134,77 @@ describe('useUASButton', () => {

expect(result.current.showButton).toBe(false);
});

describe('handleSaveAction', () => {
beforeEach(() => {
mockUseToggle.mockReturnValue({ enabled: true });
mockIsLocal.mockReturnValue(false);
(use as jest.Mock).mockReturnValue({ isSignedIn: true });
mockUasApiRequest.mockResolvedValue({ ok: true, status: 202 });
});

test('sends POST request with correct payload when saving', async () => {
mockuseUASFetchSaveStatus.mockReturnValue({
isSaved: false,
isLoading: false,
error: null,
setIsSaved: mockSetIsSaved,
});

const { result } = renderHook(() => useUASButton(defaultProps));

await act(async () => {
await result.current.handleSaveAction('save');
});

expect(mockUasApiRequest).toHaveBeenCalledWith('POST', 'favourites', {
body: {
activityType: 'favourites',
resourceDomain: 'articles',
resourceType: 'article',
resourceId: '123',
action: 'favourited',
metaData: {
service: 'hindi',
articleId: '123',
title: 'Test Article',
},
},
});
});

test('sets isSaved to true on successful save', async () => {
mockuseUASFetchSaveStatus.mockReturnValue({
isSaved: false,
isLoading: false,
error: null,
setIsSaved: mockSetIsSaved,
});

const { result } = renderHook(() => useUASButton(defaultProps));

await act(async () => {
await result.current.handleSaveAction('save');
});

expect(mockSetIsSaved).toHaveBeenCalledWith(true);
});

test('does not send POST if article is already saved', async () => {
mockuseUASFetchSaveStatus.mockReturnValue({
isSaved: true,
isLoading: false,
error: null,
setIsSaved: mockSetIsSaved,
});

const { result } = renderHook(() => useUASButton(defaultProps));

await act(async () => {
await result.current.handleSaveAction('save');
});

expect(mockUasApiRequest).not.toHaveBeenCalled();
});
});
});
53 changes: 49 additions & 4 deletions src/app/hooks/useUASButton/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { use } from 'react';
import { use, useCallback, useState } from 'react';
import useUASFetchSaveStatus from '#app/hooks/useUASFetchSaveStatus';
import { AccountContext } from '#app/contexts/AccountContext';
import isLocal from '#app/lib/utilities/isLocal';
import uasApiRequest from '#app/lib/uasApi';
import {
FAVOURITES_CONFIG,
createFavouritesPayload,
} from '#app/lib/uasApi/uasUtility';
import useToggle from '../useToggle';

/** A hook that fetches an article’s saved status and controls showing the save UAS button
Expand All @@ -11,22 +16,29 @@ import useToggle from '../useToggle';
interface UseUASButtonProps {
articleId: string;
service: string;
title: string;
}

type UASAction = 'save' | 'remove';

interface UseUASButtonReturn {
showButton: boolean;
isSaved: boolean;
isLoading: boolean;
error: Error | null;
handleSaveAction: (action: UASAction) => Promise<void>;
}

const useUASButton = ({
service,
articleId,
title,
}: UseUASButtonProps): UseUASButtonReturn => {
const { isSignedIn } = use(AccountContext);
const { enabled: featureToggleOn = false, value: accountService = '' } =
useToggle('uasPersonalization');
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<Error | null>(null);

const isUASEnabled =
featureToggleOn &&
Expand All @@ -36,14 +48,47 @@ const useUASButton = ({

const showButton = isUASEnabled && isSignedIn;

const { isSaved, isLoading, error } = useUASFetchSaveStatus(
const { isSaved, isLoading, error, setIsSaved } = useUASFetchSaveStatus(
showButton ? articleId : '',
);

const handleSaveAction = useCallback(
async (action: UASAction) => {
if (isSaving) return;

setIsSaving(true);
try {
setSaveError(null);

if (action === 'save') {
const body = createFavouritesPayload({ articleId, service, title });
await uasApiRequest('POST', FAVOURITES_CONFIG.activityType, { body });
setIsSaved(true);
} else {
// TO be implemented with https://bbc.atlassian.net/browse/WS-2212
// const globalId = buildGlobalId(articleId);
// await uasApiRequest('DELETE', FAVOURITES_CONFIG.activityType, {
// globalId,
// });
setIsSaved(false);
}
} catch (err) {
const saveErr = err instanceof Error ? err : new Error(String(err));
setSaveError(saveErr);
throw saveErr;
} finally {
setIsSaving(false);
}
},
[articleId, service, title, isSaving, setIsSaved],
);

return {
showButton,
isSaved,
isLoading,
error,
isLoading: isLoading || isSaving,
error: saveError || error,
handleSaveAction,
};
};

Expand Down
4 changes: 2 additions & 2 deletions src/app/hooks/useUASFetchSaveStatus/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { renderHook } from '#app/components/react-testing-library-with-providers';
import { waitFor } from '@testing-library/react';
import uasApiRequest from '#app/lib/uasApi';
import { buildGlobalId, ACTIVITY_TYPE } from '#app/lib/uasApi/uasUtility';
import { buildGlobalId, FAVOURITES_CONFIG } from '#app/lib/uasApi/uasUtility';
import useUASFetchSaveStatus from './index';

jest.mock('#app/lib/uasApi');
Expand Down Expand Up @@ -31,7 +31,7 @@ describe('useUASFetchSaveStatus', () => {
expect(result.current.error).toBeNull();
expect(mockUasApiRequest).toHaveBeenCalledWith(
'GET',
ACTIVITY_TYPE,
FAVOURITES_CONFIG.activityType,
expect.objectContaining({ globalId: 'global-123' }),
);
});
Expand Down
Loading
Loading