Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
8745c57
UAS GET -Single Article status retrieval with new feature toggle
jinidev Mar 24, 2026
64b0f38
adding comments
jinidev Mar 24, 2026
46edd7a
Update src/app/hooks/useUASButton/index.ts
jinidev Mar 25, 2026
738b6d5
Merge branch 'latest' of https://github.com/bbc/simorgh into ws-2209-…
jinidev Mar 25, 2026
2c9da88
updated snapshots
jinidev Mar 25, 2026
98da345
Renamed useUASFetchSavestatus hook
jinidev Mar 25, 2026
ebb3206
Merge branch 'ws-2209-GET-UAS-single-article-status' of https://githu…
jinidev Mar 25, 2026
2fc9efd
useUASbutton test updates
jinidev Mar 25, 2026
589b3d3
conflict resolved
jinidev Mar 26, 2026
baba7f3
Merge branch 'latest' of https://github.com/bbc/simorgh into ws-2209-…
jinidev Mar 26, 2026
4cf99af
Renamed Toggle name
jinidev Mar 26, 2026
1b01966
Restricted for isAmp,isLite and isAPP
jinidev Mar 26, 2026
2f7670c
Merge branch 'latest' into ws-2209-GET-UAS-single-article-status
elvinasv Mar 27, 2026
13f2038
fix: brand logo on smaller screens
elvinasv Mar 27, 2026
5989c0a
Revert "fix: brand logo on smaller screens"
elvinasv Mar 27, 2026
183d1b1
Merge branch 'latest' of https://github.com/bbc/simorgh into ws-2209-…
jinidev Mar 27, 2026
a3c0893
Default state of loading as true , removed hardcode isSignedin Value
jinidev Mar 27, 2026
0c1cbe2
review comments based changes
jinidev Mar 27, 2026
4b5b05b
Adding logs to debug
jinidev Mar 30, 2026
b2a51cf
Merge branch 'latest' of https://github.com/bbc/simorgh into ws-2209-…
jinidev Mar 30, 2026
8e5783b
eXtra logs for getAUthHeader
jinidev Mar 30, 2026
a1ee9c8
ENV vars renamed
jinidev Mar 30, 2026
98a471e
Merge branch 'latest' of https://github.com/bbc/simorgh into ws-2209-…
jinidev Mar 30, 2026
baf8b00
all UAS env vars renamed - debugging
jinidev Mar 30, 2026
168ab06
Merge branch 'latest' of https://github.com/bbc/simorgh into ws-2209-…
jinidev Mar 31, 2026
1630391
Debugging default value in apikey
jinidev Mar 31, 2026
759c3d3
Removed logs and hardcoded values
jinidev Mar 31, 2026
cf3af04
Merge branch 'latest' of https://github.com/bbc/simorgh into ws-2209-…
jinidev Mar 31, 2026
02e338b
Style updates to button
jinidev Mar 31, 2026
f4ff5eb
Merge branch 'latest' into ws-2209-GET-UAS-single-article-status
jinidev Mar 31, 2026
528709a
Merge branch 'latest' into ws-2209-GET-UAS-single-article-status
jinidev Mar 31, 2026
eb9a5a3
Merge branch 'latest' into ws-2209-GET-UAS-single-article-status
jinidev Mar 31, 2026
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
10 changes: 10 additions & 0 deletions src/app/components/SaveArticleButton/index.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** Temporary css styling until UX work is complete */
const styles = {
buttonWrapper: {
display: 'flex',
marginTop: '1rem',
marginBottom: '1rem',
},
};

export default styles;
66 changes: 66 additions & 0 deletions src/app/components/SaveArticleButton/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import useUASButton from '#app/hooks/useUASButton';
import { render, screen } from '../react-testing-library-with-providers';
import SaveArticleButton from './index';

jest.mock('#app/hooks/useUASButton');

const mockedUseUASButton = useUASButton as jest.Mock;

describe('SaveArticleButton', () => {
const defaultProps = {
isSignedIn: true,
articleId: '123',
service: 'hindi',
};

afterEach(() => {
jest.clearAllMocks();
});

test('does not render button when showButton is false', () => {
mockedUseUASButton.mockReturnValue({
showButton: false,
isSaved: false,
loading: false,
});

const { container } = render(<SaveArticleButton {...defaultProps} />);
expect(container.firstChild).toBeNull();
});

test('renders "Save for later" when not saved', () => {
mockedUseUASButton.mockReturnValue({
showButton: true,
isSaved: false,
loading: false,
});

render(<SaveArticleButton {...defaultProps} />);
expect(screen.getByRole('button')).toHaveTextContent('Save for later');
});

test('renders "Remove from saved" when saved', () => {
mockedUseUASButton.mockReturnValue({
showButton: true,
isSaved: true,
loading: false,
});

render(<SaveArticleButton {...defaultProps} />);
expect(screen.getByRole('button')).toHaveTextContent('Remove from saved');
});

test('renders loading state and disables button', () => {
mockedUseUASButton.mockReturnValue({
showButton: true,
isSaved: false,
loading: true,
});

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

expect(button).toHaveTextContent('Loading...');
expect(button).toBeDisabled();
});
});
55 changes: 55 additions & 0 deletions src/app/components/SaveArticleButton/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import useUASButton from '#app/hooks/useUASButton';
import styles from './index.styles';

interface SaveArticleButtonProps {
isSignedIn: boolean;
articleId: string;
service: 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 = ({
isSignedIn,
articleId,
service,
}: SaveArticleButtonProps) => {
const { showButton, isSaved, loading, error } = useUASButton({
isSignedIn,
articleId,
service,
});

if (!showButton) {
return null;
}
// TODO : Labels and text will be updated in a future PR to support translations and figma designs
const buttonLabel = isSaved ? 'Remove from saved' : 'Save for later';

const getButtonText = () => {
if (loading) return 'Loading...';
return isSaved ? 'Remove from saved' : 'Save for later';
};

// TODO : Will modify based on future error handling implementation,
// currently just hides the button if there is an error fetching save status
if (error) {
return null;
}

return (
<button
css={styles.buttonWrapper}
type="button"
disabled={loading}
aria-label={buttonLabel}
title={buttonLabel}
>
{getButtonText()}
</button>
);
};

export default SaveArticleButton;
121 changes: 121 additions & 0 deletions src/app/hooks/useUASButton/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { renderHook } from '#app/components/react-testing-library-with-providers';
import useFetchSaveStatus from '#app/hooks/useUASFetchSaveStatus';
import isLocal from '#app/lib/utilities/isLocal';
import useUASButton from './index';

import useToggle from '../useToggle';

jest.mock('#app/hooks/useUASFetchSaveStatus');
jest.mock('../useToggle');
jest.mock('#app/lib/utilities/isLocal');

const mockUseFetchSaveStatus = useFetchSaveStatus as jest.Mock;
const mockUseToggle = useToggle as jest.Mock;
const mockIsLocal = isLocal as jest.Mock;

describe('useUASButton', () => {
const defaultProps = {
isSignedIn: true,
articleId: '123',
service: 'hindi',
};

afterEach(() => {
jest.clearAllMocks();
});

test('returns showButton = false when feature toggle is off', () => {
mockUseToggle.mockReturnValue({ enabled: false });
mockIsLocal.mockReturnValue(false);

mockUseFetchSaveStatus.mockReturnValue({
isSaved: false,
loading: false,
error: null,
});

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

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

test('returns showButton = false when user is not signed in', () => {
mockUseToggle.mockReturnValue({ enabled: true });
mockIsLocal.mockReturnValue(false);

const { result } = renderHook(() =>
useUASButton({ ...defaultProps, isSignedIn: false }),
);

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

test('returns showButton = true when feature enabled and signed in', () => {
mockUseToggle.mockReturnValue({ enabled: true });
mockIsLocal.mockReturnValue(false);

mockUseFetchSaveStatus.mockReturnValue({
isSaved: true,
loading: false,
error: null,
});

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

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

test('passes articleId to useFetchSaveStatus when showButton is true', () => {
mockUseToggle.mockReturnValue({ enabled: true });
mockIsLocal.mockReturnValue(false);

mockUseFetchSaveStatus.mockReturnValue({
isSaved: false,
loading: false,
error: null,
});

renderHook(() => useUASButton(defaultProps));

expect(mockUseFetchSaveStatus).toHaveBeenCalledWith('123');
});

test('passes empty string when showButton is false', () => {
mockUseToggle.mockReturnValue({ enabled: false });
mockIsLocal.mockReturnValue(false);

renderHook(() => useUASButton(defaultProps));

expect(mockUseFetchSaveStatus).toHaveBeenCalledWith('');
});

test('respects local environment service filtering', () => {
mockUseToggle.mockReturnValue({
enabled: true,
value: 'hindi|sport',
});
mockIsLocal.mockReturnValue(true);

mockUseFetchSaveStatus.mockReturnValue({
isSaved: false,
loading: false,
error: null,
});

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

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

test('hides button if service not in toggle value in local', () => {
mockUseToggle.mockReturnValue({
enabled: true,
value: 'mundo',
});
mockIsLocal.mockReturnValue(true);

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

expect(result.current.showButton).toBe(false);
});
});
49 changes: 49 additions & 0 deletions src/app/hooks/useUASButton/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import useFetchSaveStatus from '#app/hooks/useUASFetchSaveStatus';
import isLocal from '#app/lib/utilities/isLocal';
import useToggle from '../useToggle';

/** A hook that fetches an article’s saved status and controls showing the save UAS button
* based on feature toggles and sign in status,
* with room to later expand for toggling the save state based on user actions. */

interface UseUASButtonProps {
isSignedIn: boolean;
articleId: string;
service: string;
}

interface UseUASButtonReturn {
showButton: boolean;
isSaved: boolean;
loading: boolean;
error: Error | null;
}

const useUASButton = ({
service,
isSignedIn,
articleId,
}: UseUASButtonProps): UseUASButtonReturn => {
const { enabled: featureToggleOn, value: accountService = '' } =
useToggle('uasEnable') || {};

const isUASEnabled =
featureToggleOn &&
(isLocal()
? accountService?.toString().split('|').includes(service)
: true);

const showButton = isUASEnabled && isSignedIn;

const { isSaved, loading, error } = useFetchSaveStatus(
showButton ? articleId : '',
);
return {
showButton,
isSaved,
loading,
error,
};
};

export default useUASButton;
65 changes: 65 additions & 0 deletions src/app/hooks/useUASFetchSaveStatus/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
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 useFetchSaveStatus from './index';

jest.mock('#app/lib/uasApi');
jest.mock('#app/lib/uasApi/uasUtility');

const mockUasApiRequest = uasApiRequest as jest.Mock;
const mockBuildGlobalId = buildGlobalId as jest.Mock;

describe('useFetchSaveStatus', () => {
const defaultArticleId = '123';

afterEach(() => {
jest.clearAllMocks();
});

test('returns isSaved = true when API returns 200', async () => {
mockBuildGlobalId.mockReturnValue('global-123');
mockUasApiRequest.mockResolvedValue({ ok: true, status: 200 });

const { result } = renderHook(() => useFetchSaveStatus(defaultArticleId));

await waitFor(() => expect(result.current.loading).toBe(false));

expect(result.current.isSaved).toBe(true);
expect(result.current.error).toBeNull();
expect(mockUasApiRequest).toHaveBeenCalledWith('GET', ACTIVITY_TYPE, {
globalId: 'global-123',
});
});

test('returns isSaved = false when API returns 204', async () => {
mockBuildGlobalId.mockReturnValue('global-123');
mockUasApiRequest.mockResolvedValue({ ok: true, status: 204 });

const { result } = renderHook(() => useFetchSaveStatus(defaultArticleId));

await waitFor(() => expect(result.current.loading).toBe(false));

expect(result.current.isSaved).toBe(false);
expect(result.current.error).toBeNull();
});

test('returns error and isSaved = false when API fails', async () => {
mockBuildGlobalId.mockReturnValue('global-123');
const apiError = new Error('API failed');
mockUasApiRequest.mockRejectedValue(apiError);

const { result } = renderHook(() => useFetchSaveStatus(defaultArticleId));

await waitFor(() => expect(result.current.loading).toBe(false));

expect(result.current.isSaved).toBe(false);
expect(result.current.error).toBe(apiError);
});

test('does not call API when articleId is empty', () => {
renderHook(() => useFetchSaveStatus(''));

expect(mockUasApiRequest).not.toHaveBeenCalled();
});
});
Loading
Loading