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
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ internal class StackHeaderConfig(
// Last resolved icon per menu item id. Unlike every other field on this config — which
// mirrors a single prop — this cache deliberately merges resolved icons from BOTH sources
// that can set a menu item icon: the `toolbarMenu` prop
// (resolveToolbarMenuItemIconsIfNeeded) and the imperative `setToolbarMenuElementOptions`
// (resolveToolbarMenuItemIconsIfNeeded) and the imperative `updateToolbarMenuElements`
// view command (dispatchMenuElementUpdate). It is necessary to ensure consistency.
private var toolbarMenuItemIcons = mapOf<String, Drawable?>()

Expand Down Expand Up @@ -368,13 +368,26 @@ internal class StackHeaderConfig(

// region Imperative menu item commands

/**
* Applies a batch of toolbar menu item view commands. Each update follows the same semantics
* as [dispatchMenuElementUpdate]: updates without icon changes are delivered immediately,
* updates with icon changes wait for (possibly async) image resolution.
*/
internal fun dispatchMenuElementUpdates(
updates: List<Triple<String, StackHeaderToolbarMenuElementOptions, StackHeaderToolbarMenuItemIconSource?>>,
) {
for ((id, options, iconSource) in updates) {
dispatchMenuElementUpdate(id, options, iconSource)
}
}

/**
* Applies a toolbar menu item view command. When the command does not touch the icon
* ([iconSource] is `null`) the options are delivered immediately. Otherwise, the icon is
* resolved first and all options — including the icon — are delivered together in a single
* update, so the change is applied atomically once the (possibly async) image has loaded.
*/
internal fun dispatchMenuElementUpdate(
private fun dispatchMenuElementUpdate(
id: String,
options: StackHeaderToolbarMenuElementOptions,
iconSource: StackHeaderToolbarMenuItemIconSource?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,17 +232,25 @@ internal open class StackHeaderConfigViewManager :
view.toolbarMenuItemIconSourceMap = iconSources
}

override fun setToolbarMenuElementOptions(
override fun updateToolbarMenuElements(
view: StackHeaderConfig,
id: String,
options: ReadableArray,
updates: ReadableArray,
) {
val map = options.getMap(0) ?: return
view.dispatchMenuElementUpdate(
id,
StackHeaderToolbarMenuMapper.parseMenuElementOptions(map),
StackHeaderToolbarMenuMapper.parseMenuElementIconSource(map),
)
val parsedUpdates =
buildList {
for (i in 0 until updates.size()) {
val map = updates.getMap(i) ?: continue
val id = map.getString("id") ?: continue
add(
Triple(
id,
StackHeaderToolbarMenuMapper.parseMenuElementOptions(map),
StackHeaderToolbarMenuMapper.parseMenuElementIconSource(map),
),
)
}
}
view.dispatchMenuElementUpdates(parsedUpdates)
}

companion object {
Expand Down
3 changes: 3 additions & 0 deletions apps/src/tests/single-feature-tests/stack-v5/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import TestStackToolbarMenuTitle from './test-stack-toolbar-menu-title-android';
import TestStackToolbarMenuIcon from './test-stack-toolbar-menu-icon-android';
import TestStackToolbarMenuGroups from './test-stack-toolbar-menu-groups-android';
import TestStackToolbarNestedMenu from './test-stack-toolbar-nested-menu-android';
import TestStackToolbarMenuBatchCommands from './test-stack-toolbar-menu-batch-commands-android';
import TestStackHeaderSubviewOnPress from './test-stack-header-subview-onpress-ios';

// Scenario entry-point components — each scenario's default export re-exported
Expand All @@ -36,6 +37,7 @@ export { default as TestStackToolbarMenuShowAsAction } from './test-stack-toolba
export { default as TestStackToolbarMenuTitle } from './test-stack-toolbar-menu-title-android';
export { default as TestStackToolbarMenuIcon } from './test-stack-toolbar-menu-icon-android';
export { default as TestStackToolbarNestedMenu } from './test-stack-toolbar-nested-menu-android';
export { default as TestStackToolbarMenuBatchCommands } from './test-stack-toolbar-menu-batch-commands-android';

const scenarios = {
TestStackPreventNativeDismissSingleStack,
Expand All @@ -54,6 +56,7 @@ const scenarios = {
TestStackToolbarMenuTitle,
TestStackToolbarMenuIcon,
TestStackToolbarNestedMenu,
TestStackToolbarMenuBatchCommands,
};

const StackScenarioGroup: ScenarioGroup<keyof typeof scenarios> = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { Button, ScrollView, StyleSheet, Text, View } from 'react-native';
import { createScenario } from '@apps/tests/shared/helpers';
import {
StackContainer,
useStackNavigationContext,
} from '@apps/shared/gamma/containers/stack';
import { ToastProvider, useToast } from '@apps/shared';
import { Colors } from '@apps/shared/styling';
import type {
StackHeaderConfigRef,
StackHeaderToolbarMenuBaseAndroid,
} from 'react-native-screens/experimental';
import { scenarioDescription } from './scenario-description';

const ITEM_IDS = ['apple', 'banana', 'cherry', 'date'] as const;

const ITEMS_CONFIG: { id: string; title: string }[] = [
{ id: 'apple', title: 'Apple' },
{ id: 'banana', title: 'Banana' },
{ id: 'cherry', title: 'Cherry' },
{ id: 'date', title: 'Date' },
];

function buildMenu(
onGroupChange: (groupId: string, selectedIds: string[]) => void,
): StackHeaderToolbarMenuBaseAndroid {
return {
groups: [
{
groupId: 'fruits',
singleSelection: false,
onSelectionChange: ids => onGroupChange('fruits', ids),
},
],
children: ITEMS_CONFIG.map(({ id, title }) => ({
type: 'menuItem',
id,
title,
groupId: 'fruits',
initialToggleState: id === 'apple',
})),
};
}

const HEADER_TITLE = 'Toolbar Menu Batch Commands Test';

function TestStackToolbarMenuBatchCommands() {
return (
<ToastProvider>
<StackContainer
routeConfigs={[
{
name: 'Main',
Component: MainScreen,
options: {
headerConfig: {
title: HEADER_TITLE,
android: { toolbarMenu: buildMenu(() => {}) },
},
},
},
]}
/>
</ToastProvider>
);
}

function MainScreen() {
const [lastEvent, setLastEvent] = useState<string | null>(null);

const headerConfigRef = useRef<StackHeaderConfigRef>(null);
const { setRouteOptions, routeKey } = useStackNavigationContext();
const toast = useToast();

const showToast = useCallback(
(text: string) => {
toast.push({ backgroundColor: Colors.GreenDark120, message: text });
},
[toast],
);

const handleGroupChange = useCallback(
(groupId: string, selectedIds: string[]) => {
const msg = `${groupId}: ${JSON.stringify(selectedIds)}`;
setLastEvent(msg);
showToast(msg);
},
[showToast],
);

useLayoutEffect(() => {
setRouteOptions(routeKey, {
headerConfig: {
title: HEADER_TITLE,
android: {
toolbarMenu: buildMenu(handleGroupChange),
},
},
headerConfigRef,
});
}, [setRouteOptions, routeKey, handleGroupChange]);

const selectAll = useCallback(() => {
headerConfigRef.current?.android?.updateToolbarMenuElements(
ITEM_IDS.map(id => ({ id, options: { checked: true } })),
);
}, []);

const deselectAll = useCallback(() => {
headerConfigRef.current?.android?.updateToolbarMenuElements(
ITEM_IDS.map(id => ({ id, options: { checked: false } })),
);
}, []);

const selectAppleAndCherry = useCallback(() => {
headerConfigRef.current?.android?.updateToolbarMenuElements([
{ id: 'apple', options: { checked: true } },
{ id: 'banana', options: { checked: false } },
{ id: 'cherry', options: { checked: true } },
{ id: 'date', options: { checked: false } },
]);
}, []);

const selectBananaSingle = useCallback(() => {
headerConfigRef.current?.android?.updateToolbarMenuElements({
id: 'banana',
options: { checked: true },
});
}, []);

return (
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
<Text style={styles.heading}>Batch Commands</Text>
<View style={styles.buttons}>
<Button title="Select All" onPress={selectAll} />
<Button title="Deselect All" onPress={deselectAll} />
<Button title="Select Apple & Cherry" onPress={selectAppleAndCherry} />
<Button
title="Select Banana (single object)"
onPress={selectBananaSingle}
/>
</View>

<Text style={styles.heading}>Last Event</Text>
<Text style={styles.result}>{lastEvent ?? '—'}</Text>
</ScrollView>
);
}

const styles = StyleSheet.create({
scroll: {
backgroundColor: Colors.cardBackground,
},
content: {
padding: 10,
paddingBottom: 50,
gap: 6,
},
heading: {
fontSize: 20,
fontWeight: 'bold',
marginTop: 12,
marginBottom: 4,
},
buttons: {
gap: 8,
},
result: {
fontSize: 15,
paddingHorizontal: 10,
},
});

export default createScenario(
TestStackToolbarMenuBatchCommands,
scenarioDescription,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { ScenarioDescription } from '@apps/tests/shared/helpers';

export const scenarioDescription: ScenarioDescription = {
name: 'Stack Toolbar Menu Batch Commands',
key: 'test-stack-toolbar-menu-batch-commands-android',
details:
'Tests batched updates via the updateToolbarMenuElements view command.',
platforms: ['android'],
e2eCoverage: 'tbd',
smokeTest: false,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Test Scenario: Stack Toolbar Menu Batch Commands (Android)

## Details

**Description:** This test verifies the batch
`updateToolbarMenuElements` view command on the Stack v5 header
for Android. It covers sending multiple menu element updates in a
single bridge call, including select all, deselect all, partial
selection, and the single-object (non-array) normalization path.

**OS test creation version:** Android: API Level 36

## E2E test

TBD — automation is possible and planned but not yet implemented.

## Prerequisites

- Android emulator or device

## Note

- Each element update in a batch that actually changes the checked
state fires a separate `onSelectionChange` event (and toast). If
an item is already in the desired state, no event fires for that
item. The toasts appear in iteration order with progressively
updated selection.

## Steps

### Baseline — initial render

1. Launch the app and navigate to **Stack Toolbar Menu Batch
Commands**. Open the overflow menu.

- [ ] Header title reads "Toolbar Menu Batch Commands Test". The
overflow menu shows 4 items: Apple (checked), Banana,
Cherry, Date. All belong to a multi-toggle group.

---

### Batch: Select All

2. Tap **Select All**.

- [ ] 3 toasts appear (apple was already checked, so no change
for it):
`fruits: ["apple","banana"]`,
`fruits: ["apple","banana","cherry"]`,
`fruits: ["apple","banana","cherry","date"]`.
- [ ] "Last event" text reads
`fruits: ["apple","banana","cherry","date"]`.
- [ ] Open the overflow menu: all 4 items are checked.

---

### Batch: Deselect All

3. Tap **Deselect All**.

- [ ] 4 toasts appear (one per item):
`fruits: ["banana","cherry","date"]`,
`fruits: ["cherry","date"]`,
`fruits: ["date"]`,
`fruits: []`.
- [ ] "Last event" text reads `fruits: []`.
- [ ] Open the overflow menu: all 4 items are unchecked.

---

### Batch: Select Specific

4. Tap **Select Apple & Cherry**.

- [ ] 2 toasts appear (banana and date were already unchecked):
`fruits: ["apple"]`,
`fruits: ["apple","cherry"]`.
- [ ] "Last event" text reads `fruits: ["apple","cherry"]`.
- [ ] Open the overflow menu: Apple and Cherry are checked. Banana
and Date are unchecked.

---

### Single object (non-array) path

5. Tap **Select Banana (single object)**.

- [ ] 1 toast appears: `fruits: ["apple","banana","cherry"]`.
- [ ] "Last event" text reads
`fruits: ["apple","banana","cherry"]`.
- [ ] Open the overflow menu: Apple, Banana, and Cherry are
checked. Date is unchecked.
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,10 @@ function MainScreen() {
...(cmdTitle !== 'no change' && { title: resolveTitle(cmdTitle) }),
...(cmdHidden !== 'no change' && { hidden: resolveHidden(cmdHidden) }),
};
headerConfigRef.current?.android?.setToolbarMenuElementOptions(
cmdTargetId,
headerConfigRef.current?.android?.updateToolbarMenuElements({
id: cmdTargetId,
options,
);
});
}, [cmdTargetId, cmdTitle, cmdHidden]);

return (
Expand Down
Loading
Loading