Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4ae5ee3
adding content card container ui component
Oct 2, 2025
e0df378
removing console logs
Oct 2, 2025
98b3a4f
removing templateType & capacity logic, fixing up styling
Oct 3, 2025
ab1177c
Merge remote-tracking branch 'upstream/content-card-containers' into …
Oct 6, 2025
e2928be
Merge remote-tracking branch 'upstream/content-card-containers' into …
Oct 6, 2025
7c04b40
addressing PR feedback
Oct 7, 2025
42ca649
more styling adjustments
Oct 7, 2025
5422924
removing trailing space
Oct 7, 2025
7c20473
Merge remote-tracking branch 'upstream/content-card-containers' into …
Oct 8, 2025
10b496e
updating metro file
Oct 8, 2025
6ba8afa
adding some basic unit tests for content card container + removing du…
Oct 8, 2025
c061ab9
removed unnecessary variable
Oct 8, 2025
e6c3c02
fixing typing
Oct 9, 2025
a0ed03f
addressing more pr feedback
Oct 9, 2025
b4c693e
Merge remote-tracking branch 'upstream/content-card-containers' into …
Oct 9, 2025
78d14c0
renaming to CardProps
Oct 10, 2025
46cee6a
adding capacity logic to content container
Oct 10, 2025
fbc93e5
Merge remote-tracking branch 'upstream/content-card-containers' into …
Oct 13, 2025
ee07c77
added chaining to the listener
Oct 13, 2025
c4d6812
adding back old app & track functionality
Oct 17, 2025
a249197
Merge remote-tracking branch 'origin/content-card-containers' into co…
Oct 20, 2025
99ff16e
Merge remote-tracking branch 'upstream/content-card-containers' into …
Oct 20, 2025
31b6b5c
adding refetching & extraData
Oct 20, 2025
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
124 changes: 45 additions & 79 deletions apps/AEPSampleAppNewArchEnabled/app/ContentCardsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,11 @@ governing permissions and limitations under the License.

import { MobileCore } from "@adobe/react-native-aepcore";
import {
ContentCardView,
ContentCardContainer,
ContentCardView,
ThemeProvider,
useContentCardUI,
Pagination,
Messaging,
ContentCardContainerProvider,
useContentContainer
} from "@adobe/react-native-aepmessaging";
import React, { memo, useCallback, useEffect, useState } from "react";
import {
Expand Down Expand Up @@ -195,40 +193,21 @@ const MemoHeader = memo(Header);

const ContentCardsView = () => {
const [selectedView, setSelectedView] = useState<ViewOption>('Remote');
const [trackInput, setTrackInput] = useState('');
const [containerSettings, setContainerSettings] = useState<any>(null);
const colorScheme = useColorScheme();
const [currentPage, setCurrentPage] = useState(1);

const surface =
Platform.OS === "android"
? "rn/android/remote_image"
: "rn/ios/remote_image";
const { content, isLoading, refetch } = useContentCardUI(surface);

// Load container settings for unread icon configuration
useEffect(() => {
const loadContainerSettings = async () => {
try {
const settings = await Messaging.getContentCardContainer(surface);
setContainerSettings(settings);
// Debug logging
// console.log('Container settings loaded:', JSON.stringify(settings, null, 2));
// console.log('isUnreadEnabled:', settings?.content?.isUnreadEnabled);
// console.log('unread_indicator:', settings?.content?.unread_indicator);
// console.log('unread_icon image URL:', settings?.content?.unread_indicator?.unread_icon?.image?.url);
// console.log('unread_icon darkUrl:', settings?.content?.unread_indicator?.unread_icon?.image?.darkUrl);
} catch (error) {
console.error('Failed to load container settings:', error);
}
};
loadContainerSettings();
}, [surface]);
const { isLoading, refetch } = useContentCardUI(surface);
const {
settings,
error,
isLoading: isLoadingContainer,
refetch: refetchContainer
} = useContentContainer(surface);

const items = ITEMS_BY_VIEW[selectedView];

const colors = colorScheme === "dark" ? Colors.dark : Colors.light;

useEffect(() => {
MobileCore.trackAction("small_image");
}, []);
Expand All @@ -237,48 +216,58 @@ const ContentCardsView = () => {
return (
<>
<MemoHeader
isLoading={false}
onTrackAction={() => {}}
isLoading={isLoading}
onTrackAction={refetchContainer}
selectedView={selectedView}
setSelectedView={setSelectedView}
/>
<ContentCardContainer
surface={surface}
settings={settings}
isLoading={isLoadingContainer}
error={error}
contentContainerStyle={styles.listContent}
refetch={refetchContainer}
/>
</>
);
}

const renderContentCard = (item: any, isRemote: boolean) => {
const cardView = <ContentCardView
template={isRemote ? item : item.template}
styleOverrides={!isRemote ? item.styleOverrides : undefined}
listener={!isRemote ? item.listener : undefined}
/>;

if (!isRemote) {
return (
<View>
<StyledText text={item.renderText} />
{item.customThemes ? (
<ThemeProvider customThemes={item.customThemes as any}>
{cardView}
</ThemeProvider>
) : (
cardView
)}
</View>
);
}
return cardView;
};

return (
<FlatList
data={items || []}
keyExtractor={(item: any) => item.key}
renderItem={({ item }: any) => {
const node = (
<ContentCardView
template={item.template}
styleOverrides={item.styleOverrides}
listener={item.listener}
/>
);
return (
<View>
<StyledText text={item.renderText} />
{item.customThemes ? (
<ThemeProvider customThemes={item.customThemes as any}>
{node}
</ThemeProvider>
) : (
node
)}
</View>
);
}}
renderItem={({ item }: any) =>
renderContentCard(item, false)
}
ListHeaderComponent={
<MemoHeader
isLoading={false}
onTrackAction={() => {}}
<MemoHeader
isLoading={isLoading}
onTrackAction={refetch}
selectedView={selectedView}
setSelectedView={setSelectedView}
/>
Expand All @@ -298,11 +287,6 @@ const styles = StyleSheet.create({
paddingTop: SPACING.s,
paddingBottom: SPACING.s,
},
headerContainer: {
marginTop: 60,
marginBottom: 15,
alignItems: "center",
},
themeSwitcher: {
width: "80%",
borderRadius: 12,
Expand Down Expand Up @@ -337,18 +321,6 @@ const styles = StyleSheet.create({
shadowOpacity: 0,
elevation: 0,
},
textTitle: {
fontSize: 16,
fontWeight: "600",
},
textBody: {
fontSize: 14,
lineHeight: SPACING.m,
},
textCaption: {
fontSize: 12,
fontStyle: "italic",
},
textLabel: {
fontSize: 14,
},
Expand Down Expand Up @@ -420,12 +392,6 @@ const styles = StyleSheet.create({
color: "white",
fontWeight: "600",
},
emptyContainer: {
borderRadius: SPACING.s,
padding: SPACING.m,
margin: SPACING.m,
alignItems: "center",
},
listContent: {
paddingBottom: SPACING.l,
},
Expand Down
4 changes: 2 additions & 2 deletions packages/messaging/src/Messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ class Messaging {
layout: {
orientation: "horizontal",
},
capacity: 10,
capacity: 2,
emptyStateSettings: {
message: {
content: "Empty State",
Expand All @@ -305,7 +305,7 @@ class Messaging {
},
},
unread_icon: {
placement: "topright",
placement: "topleft",
image: {
url: "https://icons.veryicon.com/png/o/leisure/crisp-app-icon-library-v3/notification-5.png", // Image in light mode
darkUrl: "", // Empty URL = shows dot in dark mode
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,17 @@
language governing permissions and limitations under the License.
*/

import { render, screen } from '@testing-library/react-native';
import { render, screen, act } from '@testing-library/react-native';
import React from 'react';
import { Dimensions, Text } from 'react-native';
import EmptyState from './EmptyState';
import { ContentCardContainer } from './ContentCardContainer';

// Mock hooks used by the container
jest.mock('../../hooks', () => ({
useContentCardUI: jest.fn(),
useContentContainer: jest.fn(),
}));

// Capture props passed to ContentCardView (name must start with mock for Jest scope rules)
const mockContentCardView: jest.Mock = jest.fn((..._args: any[]) => null);
jest.mock('../ContentCardView/ContentCardView', () => {
return {
Expand All @@ -33,7 +31,6 @@ jest.mock('../ContentCardView/ContentCardView', () => {
};
});

// Provide a pass-through for the provider
jest.mock('../../providers/ContentCardContainerProvider', () => ({
__esModule: true,
default: ({ children }: any) => children,
Expand Down Expand Up @@ -65,60 +62,54 @@ describe('ContentCardContainer', () => {

beforeEach(() => {
jest.clearAllMocks();
// Default Dimensions width for deterministic style assertions
jest.spyOn(Dimensions, 'get').mockReturnValue({ width: 400, height: 800, scale: 2, fontScale: 2 } as any);
});


describe('outer container states', () => {
it('renders loading state', () => {
(useContentContainer as jest.Mock).mockReturnValue({ settings: baseSettings, isLoading: false, error: null });
(useContentCardUI as jest.Mock).mockReturnValue({ content: undefined, isLoading: true, error: null });

const Loading = <Text>Loading...</Text> as any;
const CC: any = ContentCardContainer;
render(<CC surface={surface} LoadingComponent={Loading} />);
render(<CC surface={surface} settings={baseSettings} isLoading LoadingComponent={Loading} />);
expect(screen.getByText('Loading...')).toBeTruthy();
});

it('renders error state', () => {
(useContentContainer as jest.Mock).mockReturnValue({ settings: baseSettings, isLoading: false, error: new Error('x') });
(useContentCardUI as jest.Mock).mockReturnValue({ content: undefined, isLoading: false, error: null });

const ErrorComp = <Text>Error!</Text> as any;
const CC: any = ContentCardContainer;
render(<CC surface={surface} ErrorComponent={ErrorComp} />);
render(<CC surface={surface} settings={baseSettings} error={new Error('x')} ErrorComponent={ErrorComp} />);
expect(screen.getByText('Error!')).toBeTruthy();
});

it('renders fallback when no content yet', () => {
(useContentContainer as jest.Mock).mockReturnValue({ settings: undefined, isLoading: false, error: null });
it('renders fallback when no settings provided', () => {
(useContentCardUI as jest.Mock).mockReturnValue({ content: undefined, isLoading: false, error: null });

const Fallback = <Text>Fallback</Text> as any;
const CC: any = ContentCardContainer;
render(<CC surface={surface} FallbackComponent={Fallback} />);
render(<CC surface={surface} settings={null} FallbackComponent={Fallback} />);
expect(screen.getByText('Fallback')).toBeTruthy();
});

it('renders outer LoadingComponent when container is loading', () => {
(useContentContainer as jest.Mock).mockReturnValue({ settings: undefined, isLoading: true, error: null });
(useContentCardUI as jest.Mock).mockReturnValue({ content: undefined, isLoading: false, error: null });

const Loading = <Text testID="outer-loading">Loading outer...</Text> as any;
const CC: any = ContentCardContainer;
render(<CC surface={surface} LoadingComponent={Loading} />);
render(<CC surface={surface} settings={baseSettings} isLoading LoadingComponent={Loading} />);
expect(screen.getByTestId('outer-loading')).toBeTruthy();
});
});

describe('empty content rendering', () => {
it('renders empty state when content is empty', () => {
(useContentContainer as jest.Mock).mockReturnValue({ settings: baseSettings, isLoading: false, error: null });
(useContentCardUI as jest.Mock).mockReturnValue({ content: [], isLoading: false, error: null });

const CC: any = ContentCardContainer;
render(<CC surface={surface} />);
render(<CC surface={surface} settings={baseSettings} />);
expect(screen.getByText('No Content Available')).toBeTruthy();
});

Expand All @@ -133,11 +124,10 @@ describe('ContentCardContainer', () => {
} as any
}
};
(useContentContainer as jest.Mock).mockReturnValue({ settings, isLoading: false, error: null });
(useContentCardUI as jest.Mock).mockReturnValue({ content: [], isLoading: false, error: null });

const CC: any = ContentCardContainer;
const { UNSAFE_getByType } = render(<CC surface={surface} />);
const { UNSAFE_getByType } = render(<CC surface={surface} settings={settings} />);
const empty = UNSAFE_getByType(EmptyState);
expect(empty.props.image).toBe('https://example.com/light-only.png');
expect(empty.props.text).toBe('No Content Available');
Expand All @@ -151,7 +141,7 @@ describe('ContentCardContainer', () => {
const template = { id: '1', type: 'SmallImage', data: { content: { title: { content: 'T' }, body: { content: 'B' }, image: { url: 'u' } } } };
(useContentCardUI as jest.Mock).mockReturnValue({ content: [template], isLoading: false, error: null });
const CC: any = ContentCardContainer;
const { getByText } = render(<CC surface={surface} />);
const { getByText } = render(<CC surface={surface} settings={baseSettings} />);
const heading = getByText('Heading');
const styles = Array.isArray(heading.props.style) ? heading.props.style : [heading.props.style];
expect(styles.some((s: any) => s && s.color === '#FFFFFF')).toBe(true);
Expand All @@ -165,7 +155,7 @@ describe('ContentCardContainer', () => {

const ErrorComp = <Text testID="inner-error">Inner Error!</Text> as any;
const CC: any = ContentCardContainer;
render(<CC surface={surface} ErrorComponent={ErrorComp} />);
render(<CC surface={surface} settings={baseSettings} ErrorComponent={ErrorComp} />);
expect(screen.getByTestId('inner-error')).toBeTruthy();
});

Expand All @@ -177,7 +167,7 @@ describe('ContentCardContainer', () => {
<Text testID="inner-empty">{message?.content}</Text>
);
const CC: any = ContentCardContainer;
render(<CC surface={surface} EmptyComponent={<EmptyStub />} />);
render(<CC surface={surface} settings={baseSettings} EmptyComponent={<EmptyStub />} />);
expect(screen.getByTestId('inner-empty')).toBeTruthy();
expect(screen.getByText('No Content Available')).toBeTruthy();
});
Expand All @@ -200,12 +190,42 @@ describe('ContentCardContainer', () => {
(useContentCardUI as jest.Mock).mockReturnValue({ content: [template], isLoading: false, error: null });

const CC: any = ContentCardContainer;
render(<CC surface={surface} />);
render(<CC surface={surface} settings={baseSettings} />);

expect(mockContentCardView).toHaveBeenCalled();
const args = mockContentCardView.mock.calls[0][0];
expect(args.template).toEqual(template);
expect(args.style).toEqual(expect.arrayContaining([expect.anything()]));
});
});

describe('capacity and dismissal', () => {
it('renders up to capacity and backfills after dismiss', async () => {
const capSettings = {
...baseSettings,
content: { ...baseSettings.content, capacity: 2 },
};
(useContentContainer as jest.Mock).mockReturnValue({ settings: capSettings, isLoading: false, error: null });
const t1 = { id: '1', type: 'SmallImage', data: { content: { title: { content: 'T1' }, body: { content: 'B1' }, image: { url: 'u1' } } } };
const t2 = { id: '2', type: 'SmallImage', data: { content: { title: { content: 'T2' }, body: { content: 'B2' }, image: { url: 'u2' } } } };
const t3 = { id: '3', type: 'SmallImage', data: { content: { title: { content: 'T3' }, body: { content: 'B3' }, image: { url: 'u3' } } } };
(useContentCardUI as jest.Mock).mockReturnValue({ content: [t1, t2, t3], isLoading: false, error: null });

const CC: any = ContentCardContainer;
const utils = render(<CC surface={surface} settings={capSettings} />);

expect(mockContentCardView.mock.calls.length).toBe(2);
const firstProps = mockContentCardView.mock.calls[0][0];

await act(async () => {
firstProps.listener?.('onDismiss', firstProps.template);
});
utils.rerender(<CC surface={surface} settings={capSettings} extraData={() => {}} />);

const renderedIds = mockContentCardView.mock.calls.map(c => c[0].template.id);
expect(renderedIds).toEqual(expect.arrayContaining(['3']));
const lastTwoIds = renderedIds.slice(-2);
expect(lastTwoIds).not.toEqual(expect.arrayContaining(['1']));
});
});
});
Loading