Skip to content
Draft
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
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')}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Could we assign these strings (remove / save ) to an imported constant so they are maintained in a single location?

disabled={isLoading}
aria-label={buttonLabel}
title={buttonLabel}
Expand Down
70 changes: 69 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,60 @@ 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);
});
});
});
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
17 changes: 11 additions & 6 deletions src/app/hooks/useUASFetchSaveStatus/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useState } from '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 { HTTP_NO_CONTENT } from '#app/lib/statusCodes.const';

/** A hook that fetches an article’s saved status from the UAS API,
Expand All @@ -10,6 +10,7 @@ interface UseUASFetchSaveStatusReturn {
isSaved: boolean;
isLoading: boolean;
error: Error | null;
setIsSaved: (value: boolean) => void;
}

const useUASFetchSaveStatus = (
Expand All @@ -29,10 +30,14 @@ const useUASFetchSaveStatus = (

try {
const globalId = buildGlobalId(articleId);
const response = await uasApiRequest('GET', ACTIVITY_TYPE, {
globalId,
signal: abortController.signal,
});
const response = await uasApiRequest(
'GET',
FAVOURITES_CONFIG.activityType,
{
globalId,
signal: abortController.signal,
},
);

// If response is successful and not 204 (No Content), article is saved
// 204 means no content found - article not saved
Expand Down Expand Up @@ -60,7 +65,7 @@ const useUASFetchSaveStatus = (
};
}, [articleId]);

return { isSaved, isLoading, error };
return { isSaved, isLoading, error, setIsSaved };
};

export default useUASFetchSaveStatus;
Loading
Loading