mirror of
https://github.com/inaturalist/iNaturalistReactNative.git
synced 2026-05-19 13:56:58 -04:00
additional user context for feedback (#3324)
* additional context when submitting user feedback * remove log * add local obs count * add a generic breakpoint mapper as a shared function and migrate media query breakpoints to use it * remove original getBreakpoint impl after test validated * switch feedback logging to structured data
This commit is contained in:
@@ -16,20 +16,18 @@ import { RealmContext } from "providers/contexts";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Alert } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import Observation from "realmModels/Observation";
|
||||
import User from "realmModels/User";
|
||||
import { valueToBreakpoint } from "sharedHelpers/breakpoint";
|
||||
import { log } from "sharedHelpers/logger";
|
||||
import { useCurrentUser, useTranslation } from "sharedHooks";
|
||||
import useStore, { zustandStorage } from "stores/useStore";
|
||||
import { useCurrentUser, useLayoutPrefs, useTranslation } from "sharedHooks";
|
||||
import { zustandStorage } from "stores/useStore";
|
||||
import colors from "styles/tailwindColors";
|
||||
|
||||
import MenuItem from "./MenuItem";
|
||||
|
||||
const { useRealm } = RealmContext;
|
||||
|
||||
function isDefaultMode( ) {
|
||||
return useStore.getState( ).layout.isDefaultMode === true;
|
||||
}
|
||||
|
||||
interface BaseMenuOption {
|
||||
label: string;
|
||||
icon: string;
|
||||
@@ -72,6 +70,7 @@ const Menu = ( ) => {
|
||||
|
||||
const { isConnected } = useNetInfo( );
|
||||
|
||||
const layoutPrefs = useLayoutPrefs();
|
||||
const [modalState, setModalState] = useState<MenuModalState | null>( null );
|
||||
|
||||
const menuItems: Record<string, MenuOption> = {
|
||||
@@ -155,19 +154,67 @@ const Menu = ( ) => {
|
||||
navigation.goBack( );
|
||||
};
|
||||
|
||||
const onSubmitFeedback = useCallback( ( text: string ) => {
|
||||
const onSubmitFeedback = useCallback( ( feedbackText: string ) => {
|
||||
if ( !isConnected ) {
|
||||
showOfflineAlert( t );
|
||||
return false;
|
||||
}
|
||||
const mode = isDefaultMode( )
|
||||
? "DEFAULT:"
|
||||
: "ADVANCED:";
|
||||
feedbackLogger.info( mode, text );
|
||||
const locallySavedOnlyObservations = Observation.filterUnsyncedObservations( realm ).length;
|
||||
const getCountBreakpoint = ( count: number ) => valueToBreakpoint( count, [
|
||||
[0, "0"],
|
||||
[1, "1-9"],
|
||||
[10, "10-99"],
|
||||
[100, "100-999"],
|
||||
[1000, "1000+"],
|
||||
] );
|
||||
const {
|
||||
isDefaultMode,
|
||||
isAllAddObsOptionsMode,
|
||||
screenAfterPhotoEvidence,
|
||||
} = layoutPrefs;
|
||||
const modeContext = ( isDefaultMode
|
||||
? {
|
||||
mode: "default",
|
||||
observationButtonMode: "default",
|
||||
screenAfterPhotoEvidence: "default",
|
||||
}
|
||||
: {
|
||||
mode: "advanced",
|
||||
observationButtonMode: isAllAddObsOptionsMode
|
||||
? "Obs Sheet"
|
||||
: "AI Camera",
|
||||
screenAfterPhotoEvidence,
|
||||
} );
|
||||
const loggedInContext = currentUser
|
||||
? {
|
||||
loggedIn: "Yes",
|
||||
username: currentUser.login,
|
||||
identifications: typeof currentUser.identifications_count === "number"
|
||||
? getCountBreakpoint( currentUser.identifications_count )
|
||||
: "NA",
|
||||
remoteObservations: typeof currentUser.observations_count === "number"
|
||||
? getCountBreakpoint( currentUser.observations_count )
|
||||
: "NA",
|
||||
}
|
||||
: {
|
||||
loggedIn: "No",
|
||||
username: "loggedout",
|
||||
identifications: "loggedout",
|
||||
remoteObservations: "loggedout",
|
||||
};
|
||||
const feedbackWithContext = {
|
||||
text: feedbackText,
|
||||
...modeContext,
|
||||
...loggedInContext,
|
||||
// can have unsynced obs when logged out
|
||||
locallySavedOnlyObservations,
|
||||
};
|
||||
// we're logging structured data here that is parsed in Grafana
|
||||
feedbackLogger.info( feedbackWithContext );
|
||||
Alert.alert( t( "Feedback-Submitted" ), t( "Thank-you-for-sharing-your-feedback" ) );
|
||||
setModalState( null );
|
||||
return true;
|
||||
}, [isConnected, t] );
|
||||
}, [currentUser, isConnected, layoutPrefs, realm, t] );
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
|
||||
@@ -21,14 +21,10 @@ const AdvancedSettings = ( ) => {
|
||||
setScreenAfterPhotoEvidence,
|
||||
} = useLayoutPrefs();
|
||||
|
||||
const renderSettingDescription = description => (
|
||||
<Body2>{description}</Body2>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View className="mt-[20px]">
|
||||
{renderSettingDescription( t( "When-tapping-the-green-observation-button" ) )}
|
||||
<Body2>{ t( "When-tapping-the-green-observation-button" ) }</Body2>
|
||||
<RadioButtonRow
|
||||
classNames="ml-[6px] mt-[15px]"
|
||||
testID="all-observation-options"
|
||||
@@ -46,7 +42,7 @@ const AdvancedSettings = ( ) => {
|
||||
/>
|
||||
</View>
|
||||
<View className="mt-[20px]">
|
||||
{renderSettingDescription( t( "After-capturing-or-importing-photos-show" ) )}
|
||||
<Body2>{ t( "After-capturing-or-importing-photos-show" ) }</Body2>
|
||||
<RadioButtonRow
|
||||
classNames="ml-[6px] mt-[15px]"
|
||||
testID="suggestions-flow-mode"
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface TaxonNamesSettings {
|
||||
}
|
||||
class User extends Realm.Object {
|
||||
static FIELDS = {
|
||||
identifications_count: true,
|
||||
icon_url: true,
|
||||
id: true,
|
||||
locale: true,
|
||||
@@ -51,6 +52,7 @@ class User extends Realm.Object {
|
||||
primaryKey: "id",
|
||||
properties: {
|
||||
id: "int",
|
||||
identifications_count: "int?",
|
||||
icon_url: {
|
||||
type: "string",
|
||||
mapTo: "iconUrl",
|
||||
|
||||
@@ -33,7 +33,7 @@ export default {
|
||||
User,
|
||||
Vote,
|
||||
],
|
||||
schemaVersion: 66,
|
||||
schemaVersion: 67,
|
||||
path: `${RNFS.DocumentDirectoryPath}/db.realm`,
|
||||
// https://github.com/realm/realm-js/pull/6076 embedded constraints
|
||||
migrationOptions: {
|
||||
|
||||
@@ -1,6 +1,62 @@
|
||||
import { reduce } from "lodash";
|
||||
import screens from "styles/tailwindScreens";
|
||||
|
||||
/**
|
||||
* Maps a numeric value to a segment label defined by breakpoint config
|
||||
*
|
||||
* @remarks See implementation for comment of example use
|
||||
*/
|
||||
// e.g.:
|
||||
// const label = valueToBreakpoint(
|
||||
// 150,
|
||||
// [
|
||||
// [0, "0"],
|
||||
// [1, "1-9"],
|
||||
// [10, "10-99"],
|
||||
// [100, "100+"],
|
||||
// ],
|
||||
// );
|
||||
// label === "100+"
|
||||
export const valueToBreakpoint = (
|
||||
value: number,
|
||||
breakpointToLabelTuples: [breakpoint: number, label: string][],
|
||||
) => {
|
||||
const breakpointCount = breakpointToLabelTuples.length;
|
||||
if ( breakpointToLabelTuples.length < 2 ) {
|
||||
throw Error( "must provide at least 2 breakpoints" );
|
||||
}
|
||||
|
||||
// tuples are more ergonomic for the caller, but paired collections
|
||||
// are cleaner for the implementation
|
||||
const breakpoints = [];
|
||||
const labels = [];
|
||||
for ( const [breakpoint, label] of breakpointToLabelTuples ) {
|
||||
breakpoints.push( breakpoint );
|
||||
labels.push( label );
|
||||
}
|
||||
|
||||
let lastLowestBreakpoint = null;
|
||||
for ( const breakpoint of breakpoints ) {
|
||||
if ( lastLowestBreakpoint !== null && breakpoint <= lastLowestBreakpoint ) {
|
||||
throw Error( "breakpoints must be unique and ascending" );
|
||||
}
|
||||
lastLowestBreakpoint = breakpoint;
|
||||
}
|
||||
|
||||
if ( value < breakpoints[0] ) {
|
||||
throw Error( "value cannot be lower than lowest breakpoint" );
|
||||
}
|
||||
|
||||
for ( const index of breakpoints.keys() ) {
|
||||
const atFinalBreakpoint = index === breakpointCount - 1;
|
||||
const valueIsBelowNextThresold = value < breakpoints[index + 1];
|
||||
if ( atFinalBreakpoint || valueIsBelowNextThresold ) {
|
||||
return labels[index];
|
||||
}
|
||||
}
|
||||
throw Error( "breakpoint unresolvable" );
|
||||
};
|
||||
|
||||
export const BREAKPOINTS = reduce( screens, ( memo, widthString, breakpoint ) => {
|
||||
if ( typeof widthString !== "string" ) {
|
||||
throw new Error( `Unexpected breakpoint value: ${widthString}` );
|
||||
@@ -9,20 +65,15 @@ export const BREAKPOINTS = reduce( screens, ( memo, widthString, breakpoint ) =>
|
||||
return memo;
|
||||
}, { } as Record<string, number> );
|
||||
|
||||
const getBreakpoint = ( screenWidth: number ) => {
|
||||
if ( screenWidth >= BREAKPOINTS["2xl"] ) {
|
||||
return "2xl";
|
||||
}
|
||||
if ( screenWidth >= BREAKPOINTS.xl ) {
|
||||
return "xl";
|
||||
}
|
||||
if ( screenWidth >= BREAKPOINTS.lg ) {
|
||||
return "lg";
|
||||
}
|
||||
if ( screenWidth >= BREAKPOINTS.md ) {
|
||||
return "md";
|
||||
}
|
||||
return "sm";
|
||||
};
|
||||
const getBreakpoint = ( screenWidth: number ) => valueToBreakpoint( screenWidth, [
|
||||
// duplicate "sm" here to maintain "sm" as the minimum, but leave room for maybe "xs"
|
||||
// to be added as a new breakpoint which would have its own breakpoint and replace "sm" as the 0
|
||||
[0, "sm"],
|
||||
[BREAKPOINTS.sm, "sm"],
|
||||
[BREAKPOINTS.md, "md"],
|
||||
[BREAKPOINTS.lg, "lg"],
|
||||
[BREAKPOINTS.xl, "xl"],
|
||||
[BREAKPOINTS["2xl"], "2xl"],
|
||||
] );
|
||||
|
||||
export default getBreakpoint;
|
||||
|
||||
55
tests/unit/helpers/breakpoint.test.js
Normal file
55
tests/unit/helpers/breakpoint.test.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import getBreakpoint, { valueToBreakpoint } from "sharedHelpers/breakpoint";
|
||||
|
||||
describe( "breakpoint helpers", () => {
|
||||
describe( "valueToBreakpoint", () => {
|
||||
test.each( [
|
||||
[0, "0"],
|
||||
[1, "1-9"],
|
||||
[9, "1-9"],
|
||||
[10, "10-49"],
|
||||
[49, "10-49"],
|
||||
[50, "50-99"],
|
||||
[99, "50-99"],
|
||||
[100, "100+"],
|
||||
[101, "100+"],
|
||||
[1000, "100+"],
|
||||
// one-off randomish case in addition to boundary cases above
|
||||
[62, "50-99"],
|
||||
] )( "should return appropriate segment label for input", ( input, expected ) => {
|
||||
const result = valueToBreakpoint( input, [
|
||||
[0, "0"],
|
||||
[1, "1-9"],
|
||||
[10, "10-49"],
|
||||
[50, "50-99"],
|
||||
[100, "100+"],
|
||||
] );
|
||||
|
||||
expect( result ).toBe( expected );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( "getBreakpoint", () => {
|
||||
test.each( [
|
||||
// main focus on sm => md because of sm's role
|
||||
// as a non-zero breakpoint _but also_ as the default breakpoint / floor breakpoint
|
||||
[0, "sm"],
|
||||
[239, "sm"],
|
||||
[240, "sm"],
|
||||
[241, "sm"],
|
||||
[319, "sm"],
|
||||
[320, "md"],
|
||||
[321, "md"],
|
||||
[321, "md"],
|
||||
[1365, "xl"],
|
||||
[1366, "2xl"],
|
||||
[1367, "2xl"],
|
||||
] )(
|
||||
"should return appropriate media query label for screenWidth",
|
||||
( screenWidth, expected ) => {
|
||||
const result = getBreakpoint( screenWidth );
|
||||
|
||||
expect( result ).toBe( expected );
|
||||
},
|
||||
);
|
||||
} );
|
||||
} );
|
||||
Reference in New Issue
Block a user