Skip to content

Commit

Permalink
Fix unknown ballot categories crashed the vote tab
Browse files Browse the repository at this point in the history
- It's likely that new ballot categories will be added in the future, so it'd be bad if apps that hadn't updated yet started crashing
- This change updates the BallotPreviewsScreen to explicitly handle new/unknown ballot categories by displaying the BallotPreviewRow with a warning icon, title explaining that an update is needed to view the new vote, and subtitle explaining that tapping the row will start the update process (by opening the Organize app store page).
- Remove BallotTypeInfo type in favor of the more general BallotType type
  - BallotTypeInfo's navigation info was only used in BallotTypeScreen, so that navigation logic was explicitly moved to BallotTypeScreen
- Remove ballotTypeMap in favor of getBallotTypeInfo which explicitly handles the new/unknown category scenario
- Update BallotPreviewRow to handle the unknown BallotCategory
- Add appStoreURI to Routes and re-export from model/index.ts so that components don't need to import it directly from networking
  • Loading branch information
High5Apps committed Sep 26, 2024
1 parent 31a6f78 commit 3bd17d3
Show file tree
Hide file tree
Showing 10 changed files with 73 additions and 56 deletions.
16 changes: 12 additions & 4 deletions app/components/controls/lists/rows/BallotPreviewRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@ import {
} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';
import {
BallotPreview, ballotTypeMap, getMessageAge, getTimeRemaining,
BallotPreview, getBallotTypeInfo, getMessageAge, getTimeRemaining,
nominationsTimeRemainingFormatter, votingTimeRemainingFormatter,
} from '../../../../model';
import useTheme from '../../../../Theme';
import {
DisclosureIcon, HighlightedCurrentUserRowContainer,
} from '../../../views';

const UNKNOWN_BALLOT_TYPE_TITLE = 'New vote type: you must update your app to see this vote';
const UNKNOWN_BALLOT_TYPE_SUBTITLE = 'Tap to update your app';

const useStyles = () => {
const {
colors, font, sizes, spacing,
Expand Down Expand Up @@ -85,7 +88,12 @@ export default function BallotRow({ item, onPress }: Props) {
const {
category, question, userId, nominationsEndAt, votingEndsAt,
} = item;
const subtitle = getSubtitle(votingEndsAt, nominationsEndAt);
const ballotTypeInfo = getBallotTypeInfo(category);
const title = (ballotTypeInfo.category === 'unknown')
? UNKNOWN_BALLOT_TYPE_TITLE : question;
const subtitle = (ballotTypeInfo.category === 'unknown')
? UNKNOWN_BALLOT_TYPE_SUBTITLE
: getSubtitle(votingEndsAt, nominationsEndAt);

const { colors, styles } = useStyles();

Expand All @@ -98,9 +106,9 @@ export default function BallotRow({ item, onPress }: Props) {
style={styles.container}
userIds={[userId]}
>
<Icon name={ballotTypeMap[category].iconName} style={styles.icon} />
<Icon name={ballotTypeInfo.iconName} style={styles.icon} />
<View style={styles.innerContainer}>
<Text style={[styles.text, styles.title]}>{question}</Text>
<Text style={[styles.text, styles.title]}>{title}</Text>
<Text style={[styles.text, styles.subtitle]}>{subtitle}</Text>
</View>
<DisclosureIcon />
Expand Down
25 changes: 11 additions & 14 deletions app/model/BallotTypes.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,34 @@
import { useEffect, useMemo } from 'react';
import { useMyPermissions } from './context';
import { BallotCategory, BallotTypeInfo } from './types';
import { BallotType } from './types';

const ballotTypes: BallotTypeInfo[] = [
const ballotTypes: BallotType[] = [
{
category: 'yes_no',
iconName: 'thumb-up',
name: 'Yes or No',
newScreenName: 'NewYesOrNoBallot',
},
{
category: 'multiple_choice',
iconName: 'check-box',
name: 'Multiple Choice',
newScreenName: 'NewMultipleChoiceBallot',
},
{
category: 'election',
iconName: 'person',
name: 'Election',
newScreenName: 'NewElectionBallot',
subtypeSelectionScreenName: 'OfficeAvailability',
},
];

type BallotTypeMap = {
[key in BallotCategory]: Omit<BallotTypeInfo, 'category'>;
};

export const ballotTypeMap = ballotTypes.reduce((
accumulator,
{ category, ...rest },
) => ({ ...accumulator, [category]: rest }), {} as BallotTypeMap);
export function getBallotTypeInfo(category: string): BallotType {
return ballotTypes.find(
(ballotType) => ballotType.category === category,
) ?? {
category: 'unknown',
iconName: 'warning',
name: 'Unknown',
};
}

export default function useBallotTypes() {
const { can, refreshMyPermissions } = useMyPermissions({
Expand Down
6 changes: 4 additions & 2 deletions app/model/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { default as useAppState } from './AppState';
export { default as useBallotTypes, ballotTypeMap } from './BallotTypes';
export { default as useBallotTypes, getBallotTypeInfo } from './BallotTypes';
export * from './Config';
export { default as useConnection } from './Connection';
export { default as createCurrentUser } from './CurrentUserCreation';
Expand All @@ -26,4 +26,6 @@ export * from './formatters';
export * from './keys';
export * from './types';

export { privacyPolicyURI, termsOfServiceURI } from '../networking';
export {
appStoreURI, privacyPolicyURI, termsOfServiceURI,
} from '../networking';
11 changes: 0 additions & 11 deletions app/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,6 @@ export type BallotType = {
name: string;
};

type NewBallotSubtypeSelectionScreen = 'OfficeAvailability';
type NewBallotScreen = 'NewYesOrNoBallot' | 'NewMultipleChoiceBallot' | 'NewElectionBallot';

export type BallotTypeInfo = {
category: BallotCategory;
iconName: string;
name: string;
newScreenName: NewBallotScreen;
subtypeSelectionScreenName?: NewBallotSubtypeSelectionScreen;
};

export type Model = {
id: string;
};
Expand Down
4 changes: 4 additions & 0 deletions app/networking/BallotAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ export async function fetchBallotPreviews({
({ encryptedQuestion, ...b }, i) => ({ ...b, question: questions[i]! }),
);

// Uncomment the code below to test the effects of a new ballot category on an
// old app version
// ballotPreviews[0].category = 'unknown';

return { ballotPreviews, paginationData };
}

Expand Down
1 change: 1 addition & 0 deletions app/networking/Routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { PermissionScope } from './types';
const prodOrigin = 'https://getorganize.app';
const origin = __DEV__ ? 'http://localhost:8080' : prodOrigin;

export const appStoreURI = ({ ref }: { ref: string }) => `${prodOrigin}/store?ref=${ref}`;
export const privacyPolicyURI = `${prodOrigin}/privacy`;
export const termsOfServiceURI = `${prodOrigin}/terms`;

Expand Down
2 changes: 1 addition & 1 deletion app/networking/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export {
createPermission, fetchPermission, fetchMyPermissions,
} from './PermissionAPI';
export { createPost, fetchPost, fetchPosts } from './PostAPI';
export { privacyPolicyURI, termsOfServiceURI } from './Routes';
export { appStoreURI, privacyPolicyURI, termsOfServiceURI } from './Routes';
export { createTerm } from './TermAPI';
export { default as createOrUpdateUpvote } from './UpvoteAPI';
export {
Expand Down
2 changes: 1 addition & 1 deletion app/networking/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ export function isCommentThreadResponse(object: unknown): object is CommentThrea
return response?.thread && isCommentThreadComment(response.thread);
}

export type BallotCategory = 'yes_no' | 'multiple_choice' | 'election';
export type BallotCategory = 'yes_no' | 'multiple_choice' | 'election' | 'unknown';

export type BallotIndexBallot = {
category: BallotCategory,
Expand Down
39 changes: 25 additions & 14 deletions app/screens/vote/BallotPreviewsScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React from 'react';
import { StyleSheet } from 'react-native';
import React, { useCallback } from 'react';
import { Linking, StyleSheet } from 'react-native';
import {
BallotPreviewList, PrimaryButton, ScreenBackground,
} from '../../components';
import useTheme from '../../Theme';
import type { BallotPreviewsScreenProps } from '../../navigation';
import { appStoreURI, BallotPreview } from '../../model';

const useStyles = () => {
const { sizes, spacing } = useTheme();
Expand Down Expand Up @@ -33,22 +34,32 @@ export default function VoteScreen({
}: BallotPreviewsScreenProps) {
const prependedBallotId = route.params?.prependedBallotId;
const { styles } = useStyles();

const onItemPress = useCallback(({
category, id, nominationsEndAt, votingEndsAt,
}: BallotPreview) => {
if (category === 'unknown') {
Linking.openURL(appStoreURI({ ref: 'ballot-preview-row' }));
return;
}

const now = new Date().getTime();
const inNominations = now < (nominationsEndAt?.getTime() ?? 0);
const active = now < votingEndsAt.getTime();
let screenName: 'Ballot' | 'Nominations' | 'Result';
if (active) {
screenName = inNominations ? 'Nominations' : 'Ballot';
} else {
screenName = 'Result';
}
navigation.navigate(screenName, { ballotId: id });
}, [navigation]);

return (
<ScreenBackground>
<BallotPreviewList
contentContainerStyle={styles.contentContainerStyle}
onItemPress={({ id, nominationsEndAt, votingEndsAt }) => {
const now = new Date().getTime();
const inNominations = now < (nominationsEndAt?.getTime() ?? 0);
const active = now < votingEndsAt.getTime();
let screenName: 'Ballot' | 'Nominations' | 'Result';
if (active) {
screenName = inNominations ? 'Nominations' : 'Ballot';
} else {
screenName = 'Result';
}
navigation.navigate(screenName, { ballotId: id });
}}
onItemPress={onItemPress}
prependedBallotId={prependedBallotId}
/>
<PrimaryButton
Expand Down
23 changes: 14 additions & 9 deletions app/screens/vote/BallotTypeScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import React, { useCallback } from 'react';
import { BallotTypeList, ScreenBackground } from '../../components';
import type { BallotTypeScreenProps } from '../../navigation';
import { BallotType, ballotTypeMap } from '../../model';
import { BallotType } from '../../model';

type NavigationTarget =
'OfficeAvailability' | 'NewMultipleChoiceBallot' | 'NewYesOrNoBallot';

export default function BallotTypeScreen({
navigation,
}: BallotTypeScreenProps) {
const onBallotTypeRowPress = useCallback(({ category }: BallotType) => {
const {
newScreenName, subtypeSelectionScreenName,
} = ballotTypeMap[category];
const screen = subtypeSelectionScreenName ?? newScreenName;

// @ts-ignore because params are only required for screens that use a
// subtypeSelectionScreen first to determine the relevant params, instead of
// navigating directly to the newScreen
let screen: NavigationTarget;
if (category === 'election') {
screen = 'OfficeAvailability';
} else if (category === 'multiple_choice') {
screen = 'NewMultipleChoiceBallot';
} else if (category === 'yes_no') {
screen = 'NewYesOrNoBallot';
} else {
throw new Error(`Unhandled ballot category: ${category}`);
}
navigation.navigate(screen);
}, [navigation]);

Expand Down

0 comments on commit 3bd17d3

Please sign in to comment.