I have a component that is giving me the following issues:
TEST STEPS:
EXPECTED RESULTS:
The answer for the first Get Involved question should still be selected.
ACTUAL RESULTS:
It is not selected.
So it seems the issue is the callback that updates some state on the parent, but the parent is not passing the yes/no prop to this component and it's unmounting to spare me rendering.
On the backend, in the database, the answers are being recorded, but it's not persisting in the UI. I noticed that the helper functions below, specifically the this.props.onPress()
return undefined
.
class GetInvolvedFeedCard extends PureComponent {
static propTypes = {
onPress: PropTypes.func.isRequired,
style: PropTypes.oneOfType([PropTypes.number, PropTypes.object]),
title: PropTypes.string.isRequired
};
constructor(props) {
super(props);
const helper = `${
this.props.title.endsWith("?") ? "" : "."
} Your NFIB preferences will be updated.`;
this.state = {
noSelected: false,
yesSelected: false,
helper
};
}
_accept = () => {
this.setState({ yesSelected: true, noSelected: false }, () => {
this.props.onPress(true);
});
};
_decline = () => {
this.setState({ noSelected: true, yesSelected: false }, () => {
this.props.onPress(false);
});
};
render() {
return (
<Card style={this.props.style}>
<View style={feedContentStyles.header}>
<View style={feedContentStyles.contentType}>
<Text style={feedContentStyles.title}>{"GET INVOLVED"}</Text>
</View>
</View>
<Divider />
<View style={feedContentStyles.content}>
<View style={styles.content}>
<Text style={styles.blackTitle}>
{this.props.title}
<Text style={styles.italicText}>{this.state.helper}</Text>
</Text>
</View>
<View style={styles.footer}>
<TouchableOpacity onPress={this._decline}>
<Text
style={[
styles.btnText,
styles.noBtn,
this.state.noSelected ? styles.selected : null
]}
>
{"NO"}
</Text>
</TouchableOpacity>
<TouchableOpacity onPress={this._accept}>
<Text
style={[
styles.btnText,
this.state.yesSelected ? styles.selected : null
]}
>
{"YES"}
</Text>
</TouchableOpacity>
</View>
</View>
</Card>
);
}
}
So it appears that the state is being managed by the parent component as opposed to Redux.
What is unclear is, whether or not it is unmounting the component that defines onPress
. It sounds like it is.
Just scrolling, so there is an activity feed composed of cards some of them render boolean type questions
do you want to get involved? no yes
When a user selects either response, then scrolls down a certain amount and then scrolls back up to that same question, it's like the user never answered it. I noticed when the user selects NO, the this.props.handleUpdateGetInvolved({ involved: items }) from _handleGetInvolved function is not fired only when the user selects YES. Referring to this:
_handleGetInvolved = (response, entity) => {
if (response !== entity.IsSelected) {
const isTopic = entity.Category !== "GetInvolved";
const items = [
{
...entity,
IsSelected: response
}
];
if (isTopic) {
this.props.handleUpdateTopics({ topics: items });
} else {
this.props.handleUpdateGetInvolved({ involved: items });
}
}
};
the helper functions themselves for each answer always returns undefined inside the GetInvolvedFeedCard
component:
_accept = () => {
this.setState({ yesSelected: true, noSelected: false }, () => {
this.props.onPress(true);
console.log(
"this is the accept helper function: ",
this.props.onPress(true)
);
});
};
_decline = () => {
this.setState({ noSelected: true, yesSelected: false }, () => {
this.props.onPress(false);
console.log(
"this is the decline helper function: ",
this.props.onPress(false)
);
});
};
render() {
return (
<Card style={this.props.style}>
<View style={feedContentStyles.header}>
<View style={feedContentStyles.contentType}>
<Text style={feedContentStyles.title}>{"GET INVOLVED"}</Text>
</View>
</View>
<Divider />
<View style={feedContentStyles.content}>
<View style={styles.content}>
<Text style={styles.blackTitle}>
{this.props.title}
<Text style={styles.italicText}>{this.state.helper}</Text>
</Text>
</View>
<View style={styles.footer}>
<TouchableOpacity onPress={this._decline}>
<Text
style={[
styles.btnText,
styles.noBtn,
this.state.noSelected ? styles.selected : null
]}
>
{"NO"}
</Text>
</TouchableOpacity>
<TouchableOpacity onPress={this._accept}>
<Text
style={[
styles.btnText,
this.state.yesSelected ? styles.selected : null
]}
>
{"YES"}
</Text>
</TouchableOpacity>
</View>
If I am not mistaken all this is being rendered overall by the ActivityFeed
component:
const { height } = Dimensions.get("window");
export class ActivityFeed extends PureComponent {
static propTypes = {
displayAlert: PropTypes.bool,
feed: PropTypes.array,
fetchFeed: PropTypes.func,
getCampaignDetails: PropTypes.func,
handleContentSwipe: PropTypes.func,
handleUpdateGetInvoved: PropTypes.func,
handleUpdateTopics: PropTypes.func,
hideUndoAlert: PropTypes.func,
lastSwippedElement: PropTypes.object,
loading: PropTypes.bool,
navigation: PropTypes.object,
setSelectedAlert: PropTypes.func,
setSelectedArticle: PropTypes.func,
setSelectedEvent: PropTypes.func,
setSelectedSurvey: PropTypes.func.isRequired,
undoSwipeAction: PropTypes.func,
userEmailIsValidForVoterVoice: PropTypes.bool
};
constructor(props) {
super(props);
this.prompted = false;
this.state = {
refreshing: false,
appState: AppState.currentState
};
}
async componentDidMount() {
AppState.addEventListener("change", this._handleAppStateChange);
if (!this.props.loading) {
const doRefresh = await cache.shouldRefresh("feed");
if (this.props.feed.length === 0 || doRefresh) {
this.props.fetchFeed();
}
cache.incrementAppViews();
}
}
componentWillUnmount() {
AppState.removeEventListener("change", this._handleAppStateChange);
}
_handleAppStateChange = async appState => {
if (
this.state.appState.match(/inactive|background/) &&
appState === "active"
) {
cache.incrementAppViews();
const doRefresh = await cache.shouldRefresh("feed");
if (doRefresh) {
this.props.fetchFeed();
}
}
this.setState({ appState });
};
_keyExtractor = ({ Entity }) =>
(Entity.Key || Entity.Id || Entity.CampaignId || Entity.Code).toString();
_gotoEvent = event => {
cache.setRouteStarter("MainDrawer");
this.props.setSelectedEvent(event);
const title = `${event.LegislatureType} Event`;
this.props.navigation.navigate("EventDetails", { title });
};
_gotoSurveyBallot = survey => {
cache.setRouteStarter("MainDrawer");
this.props.setSelectedSurvey(survey);
this.props.navigation.navigate("SurveyDetails");
};
_gotoArticle = article => {
cache.setRouteStarter("MainDrawer");
this.props.setSelectedArticle(article);
this.props.navigation.navigate("ArticleDetails");
};
_onAlertActionButtonPress = async item => {
cache.setRouteStarter("MainDrawer");
await this.props.setSelectedAlert(item.Entity);
this.props.getCampaignDetails();
if (this.props.userEmailIsValidForVoterVoice) {
this.props.navigation.navigate("Questionnaire");
} else {
this.props.navigation.navigate("UnconfirmedEmail");
}
};
_onSwipedOut = (swippedItem, index) => {
this.props.handleContentSwipe(this.props, { swippedItem, index });
};
_handleGetInvolved = (response, entity) => {
if (response !== entity.IsSelected) {
const isTopic = entity.Category !== "GetInvolved";
const items = [
{
...entity,
IsSelected: response
}
];
if (isTopic) {
this.props.handleUpdateTopics({ topics: items });
} else {
this.props.handleUpdateGetInvoved({ involved: items });
}
}
};
renderItem = ({ item, index }) => {
const { Type, Entity } = item;
if (Type === "EVENT") {
return (
<SwippableCard onSwipedOut={() => this._onSwipedOut(item, index)}>
<EventFeedCard
style={styles.push}
mainActionButtonPress={() => this._gotoEvent(Entity)}
event={Entity}
/>
</SwippableCard>
);
}
if (["SURVEY_SURVEY", "SURVEY_BALLOT"].includes(Type)) {
return (
<SwippableCard onSwipedOut={() => this._onSwipedOut(item, index)}>
<SurveyBallotFeedCard
style={styles.push}
survey={Entity}
handleViewDetails={() => this._gotoSurveyBallot(Entity)}
/>
</SwippableCard>
);
}
if (Type === "SURVEY_MICRO") {
return (
<SwippableCard onSwipedOut={() => this._onSwipedOut(item, index)}>
<MicroSurvey style={styles.push} selectedSurvey={Entity} />
</SwippableCard>
);
}
if (Type === "ALERT") {
return (
<SwippableCard onSwipedOut={() => this._onSwipedOut(item, index)}>
<ActionAlertFeedCard
datePosted={Entity.StartDateUtc}
style={styles.push}
title={Entity.Headline}
content={Entity.Alert}
mainActionButtonPress={() => this._onAlertActionButtonPress(item)}
secondaryActionButtonPress={() => {
this.props.setSelectedAlert(Entity);
// eslint-disable-next-line
this.props.navigation.navigate("ActionAlertDetails", {
content: Entity.Alert,
id: Entity.CampaignId,
title: Entity.Headline
});
}}
/>
</SwippableCard>
);
}
if (Type === "ARTICLE") {
return (
<SwippableCard onSwipedOut={() => this._onSwipedOut(item, index)}>
<ArticleFeedCard
content={Entity}
style={styles.push}
mainActionButtonPress={() => this._gotoArticle(Entity)}
/>
</SwippableCard>
);
}
//prettier-ignore
if (Type === 'NOTIFICATION' && Entity.Code === 'INDIVIDUAL_ADDRESS_HOME_MISSING') {
return (
<MissingAddressCard
style={styles.push}
navigate={() => this.props.navigation.navigate('HomeAddress')}
/>
);
}
if (["PREFERENCE_TOPIC", "PREFERENCE_INVOLVEMENT"].includes(Type)) {
return (
<SwippableCard onSwipedOut={() => this._onSwipedOut(item, index)}>
<GetInvolvedFeedCard
style={styles.push}
title={Entity.DisplayText}
onPress={response => this._handleGetInvolved(response, Entity)}
/>
</SwippableCard>
);
}
return null;
};
_onRefresh = async () => {
try {
this.setState({ refreshing: true });
this.props
.fetchFeed()
.then(() => {
this.setState({ refreshing: false });
})
.catch(() => {
this.setState({ refreshing: false });
});
} catch (e) {
this.setState({ refreshing: false });
}
};
_trackScroll = async event => {
try {
if (this.prompted) {
return;
}
const y = event.nativeEvent.contentOffset.y;
const scrollHeight = height * 0.8;
const page = Math.round(Math.floor(y) / scrollHeight);
const alert = await cache.shouldPromtpPushNotificationPermissions();
const iOS = Platform.OS === "ios";
if (alert && iOS && page > 1) {
this.prompted = true;
this._openPromptAlert();
}
} catch (e) {
return false;
}
};
_openPromptAlert = () => {
Alert.alert(
"Push Notifications Access",
"Stay engaged with NFIB on the issues and activities you care about by allowing us to notify you using push notifications",
[
{
text: "Deny",
onPress: () => {
cache.pushNotificationsPrompted();
},
style: "cancel"
},
{
text: "Allow",
onPress: () => {
OneSignal.registerForPushNotifications();
cache.pushNotificationsPrompted();
}
}
],
{ cancelable: false }
);
};
_getAlertTitle = () => {
const { lastSwippedElement } = this.props;
const { Type } = lastSwippedElement.swippedItem;
if (Type.startsWith("PREFERENCE")) {
return "Preference Dismissed";
}
switch (Type) {
case "EVENT":
return "Event Dismissed";
case "SURVEY_BALLOT":
return "Ballot Dismissed";
case "SURVEY_SURVEY":
return "Survey Dismissed";
case "SURVEY_MICRO":
return "Micro Survey Dismissed";
case "ARTICLE":
return "Article Dismissed";
case "ALERT":
return "Action Alert Dismissed";
default:
return "Dismissed";
}
};
render() {
if (this.props.loading && !this.state.refreshing) {
return <Loading />;
}
const contentStyles =
this.props.feed.length > 0 ? styles.content : emptyStateStyles.container;
return (
<View style={styles.container}>
<FlatList
contentContainerStyle={contentStyles}
showsVerticalScrollIndicator={false}
data={this.props.feed}
renderItem={this.renderItem}
keyExtractor={this._keyExtractor}
removeClippedSubviews={false}
onRefresh={this._onRefresh}
refreshing={this.state.refreshing}
ListEmptyComponent={() => (
<EmptyState navigation={this.props.navigation} />
)}
scrollEventThrottle={100}
onScroll={this._trackScroll}
/>
{this.props.displayAlert && (
<BottomAlert
title={this._getAlertTitle()}
onPress={this.props.undoSwipeAction}
hideAlert={this.props.hideUndoAlert}
/>
)}
</View>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1
},
content: {
paddingHorizontal: scale(8),
paddingTop: scale(16),
paddingBottom: scale(20)
},
push: {
marginBottom: 16
}
});
const mapState2Props = ({
activityFeed,
auth: { userEmailIsValidForVoterVoice },
navigation
}) => {
return {
...activityFeed,
userEmailIsValidForVoterVoice,
loading: activityFeed.loading || navigation.deepLinkLoading
};
};
export default connect(mapState2Props, {
fetchFeed,
getCampaignDetails,
handleUpdateGetInvoved,
handleUpdateTopics,
setSelectedAlert,
setSelectedArticle,
setSelectedEvent,
setSelectedSurvey,
handleContentSwipe,
undoSwipeAction,
hideUndoAlert
})(ActivityFeed);
This is what the Get Involved card looks like:
So when the user clicks NO or YES, then scrolls that card away from view, the expectation is when they scroll that card back into view NO or YES, whichever one selected, should still be there.
Also, the answer selected only disappears when the user scrolls all the way to the bottom of the activity feed and then back up, but if the user only scrolls halfway through the activity feed cards and returns to this Get Involved card, the answer selected does not go away.
I believe the below SO article answer is exactly what is happening to me: React Native - FlatList - Internal State.
So it seems the answer here would be to pass the answer from the application state as a prop and render based on that prop, and not the internal state of the card but I am not entirely sure what that looks like or how to put it together.
constructor(props) {
super(props);
// const helper = `${
// this.props.title.endsWith("?") ? "" : "."
// } Your NFIB preferences will be updated.`;
this.state = {
noSelected: false,
yesSelected: false,
helper
};
}
_accept = () => {
this.setState({ yesSelected: true, noSelected: false }, () => {
this.props.onPress(true);
});
};
_decline = () => {
this.setState({ noSelected: true, yesSelected: false }, () => {
this.props.onPress(false);
});
};
with:
this.state = {
// noSelected: false,
// yesSelected: false,
// helper
cardSelectedStatus: []
};
with the idea of then implementing cardSelectedStatus in my mapStateToProps
function mapStateToProps(state) {
return {cardSelectedStatus: state.};
}
But then I realized I am not sure what I am passing in as there are no action creators involved in this component, therefore no reducers
so can I use mapStateToProps
if there is no action creator/reducer involved in this component?
Utilizing mapStateToProps is the only way I know or it's the way with the most experience I have in passing the user's input from the application state as a prop and render based on that prop, and not the internal state.
So after several weeks of hammering away at this and posting the above question, here is what I finally honed in on:
isSelected: false
in the Redux state.isSelected
to true
.isSelected: true
.So based on this, because isSelected
does not have a neutral value, that is, starts out as false
, which would equate to No, I cannot rely on Redux state for rendering the selection, which could have solved this.
Instead, in the interest of time, the only solution I could think of with much input from others includint tuledev, was to set <FlatList windowSize={500} />
.
This is not an elegant solution but it will get FlatList
to behave more like ScrollView
in that preserve all the components so my component state will not get wiped out when a user scrolls to far away from it. This works without actually replacing it to ScrollView
since there are properties and methods with FlatList
that would not work by just dropping it into ScrollView
same as it is in FlatList
, for example, there is no renderItem
property in ScrollView
.