javascriptreactjsreact-native

How do I pass the answer from application state as a prop and not the internal state of the card


I have a component that is giving me the following issues:

TEST STEPS:

  1. Login to Mobile.
  2. On the Activity Feed, answer the first Get Involved question.
  3. Scroll to the last Get Involved question and answer it.
  4. Scroll back to the first Get Involved question that was answered.

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:

enter image description here

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.


Solution

  • So after several weeks of hammering away at this and posting the above question, here is what I finally honed in on:

    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.