301 flag id functionality obsdetail (#333)

* Flag Id Modal created.

* Flag item modal radio buttons, styling, cancel button

* Flag Item Modal, Api Flags file created

* Added id field in Identifications realm schema, Flag Item Modal API call

* Clear form, toggle function, click title not just checkbox to toggle

* Fixed android checkbox toggle.

* Clean up. Flag ID ObsDetail. Closes #301.

* Flag realm model, flagged status shown on initial load

* Refresh observation after item flagged. In Progress.

* Remove console logs, add onError

* save button loading spinner

* Realm 31 migration for obsservation updated_at

* Flags test file

* FlagItemModal tests

* FlagItemModal tests

---------

Co-authored-by: Amanda Bullington <albullington@gmail.com>
This commit is contained in:
Angie
2023-02-03 13:09:56 -08:00
committed by GitHub
parent ba181e75fd
commit e14d620992
14 changed files with 519 additions and 10 deletions

23
src/api/flags.js Normal file
View File

@@ -0,0 +1,23 @@
// @flow
import inatjs from "inaturalistjs";
import handleError from "./error";
const PARAMS = {
fields: "all"
};
const createFlag = async (
params: Object = {},
opts: Object = {}
): Promise<any> => {
try {
const { results } = await inatjs.flags.create( { ...PARAMS, ...params }, opts );
return results;
} catch ( e ) {
return handleError( e );
}
};
export default createFlag;

View File

@@ -3,11 +3,13 @@
import { useQueryClient } from "@tanstack/react-query";
import { deleteComments } from "api/comments";
import { isCurrentUser } from "components/LoginSignUp/AuthenticationService";
import FlagItemModal from "components/ObsDetails/FlagItemModal";
import InlineUser from "components/SharedComponents/InlineUser";
import KebabMenu from "components/SharedComponents/KebabMenu";
import UserText from "components/SharedComponents/UserText";
import {
Image, Pressable, Text, View
Image,
Pressable, Text, View
} from "components/styledComponents";
import { t } from "i18next";
import _ from "lodash";
@@ -39,11 +41,17 @@ const ActivityItem = ( {
}: Props ): Node => {
const [currentUser, setCurrentUser] = useState( null );
const [kebabMenuVisible, setKebabMenuVisible] = useState( false );
const [flagModalVisible, setFlagModalVisible] = useState( false );
const [flaggedStatus, setFlaggedStatus] = useState( false );
const { taxon } = item;
const { user } = item;
const realm = useRealm( );
const queryClient = useQueryClient( );
const itemType = item.category ? "Identification" : "Comment";
const activityItemClassName = flaggedStatus
? "flex-row border border-borderGray py-1 justify-between bg-flaggedBackground"
: "flex-row border border-borderGray py-1 justify-between";
const isOnline = useIsConnected( );
useEffect( ( ) => {
@@ -52,7 +60,12 @@ const ActivityItem = ( {
setCurrentUser( current );
};
isActiveUserTheCurrentUser( );
}, [user] );
// show flagged activity item right after flag item modal closes
if ( item.flags?.length > 0 ) {
setFlaggedStatus( true );
}
}, [user, item] );
const deleteCommentMutation = useAuthenticatedMutation(
( uuid, optsWithAuth ) => deleteComments( uuid, optsWithAuth ),
@@ -64,6 +77,15 @@ const ActivityItem = ( {
}
);
const closeFlagItemModal = () => {
setFlagModalVisible( false );
};
const onItemFlagged = () => {
setFlaggedStatus( true );
refetchRemoteObservation();
};
const showNoInternetIcon = accessibilityLabel => (
<View className="mr-3">
<IconMaterial
@@ -77,7 +99,7 @@ const ActivityItem = ( {
return (
<View className={item.temporary && "opacity-50"}>
<View className="flex-row border border-borderGray py-1 justify-between">
<View className={activityItemClassName}>
{user && <InlineUser user={user} />}
<View className="flex-row items-center">
{item.vision
@@ -87,9 +109,19 @@ const ActivityItem = ( {
source={require( "images/id_rg.png" )}
/>
)}
<Text className="color-inatGreen mr-2">
{item.category ? t( `Category-${item.category}` ) : ""}
</Text>
{
flaggedStatus
? (
<Text className="color-flaggedText mr-2">
{t( "Flagged" )}
</Text>
)
: (
<Text className="color-inatGreen mr-2">
{item.category ? t( `Category-${item.category}` ) : ""}
</Text>
)
}
{item.created_at
&& (
<Text>
@@ -119,7 +151,13 @@ const ActivityItem = ( {
visible={kebabMenuVisible}
setVisible={setKebabMenuVisible}
>
{/* TODO: build out this menu */}
{!currentUser ? (
<Menu.Item
onPress={() => setFlagModalVisible( true )}
title={t( "Flag" )}
testID="MenuItem.Flag"
/>
) : undefined}
<View />
</KebabMenu>
)}
@@ -150,6 +188,16 @@ const ActivityItem = ( {
<UserText baseStyle={textStyles.activityItemBody} text={item.body} />
</View>
)}
{!currentUser
&& (
<FlagItemModal
id={item.id}
showFlagItemModal={flagModalVisible}
closeFlagItemModal={closeFlagItemModal}
itemType={itemType}
onItemFlagged={onItemFlagged}
/>
)}
</View>
);
};

View File

@@ -0,0 +1,201 @@
// @flow
import CheckBox from "@react-native-community/checkbox";
import createFlag from "api/flags";
import Button from "components/SharedComponents/Buttons/Button";
import {
Modal,
SafeAreaView,
Text, View
} from "components/styledComponents";
import { t } from "i18next";
import type { Node } from "react";
import React, { useRef, useState } from "react";
import {
Alert,
findNodeHandle
} from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
import { TextInput } from "react-native-paper";
import IconMaterial from "react-native-vector-icons/MaterialIcons";
import useAuthenticatedMutation from "sharedHooks/useAuthenticatedMutation";
type Props = {
id:number,
itemType:string,
showFlagItemModal: boolean,
closeFlagItemModal: Function,
onItemFlagged: Function
}
const FlagItemModal = ( {
id, itemType, showFlagItemModal, closeFlagItemModal, onItemFlagged
}: Props ): Node => {
const keyboardScrollRef = useRef( null );
const [checkBoxValue, setCheckBoxValue] = useState( "none" );
const [explanation, setExplanation] = useState( "" );
const [loading, setLoading] = useState( false );
const showErrorAlert = error => Alert.alert(
"Error",
error,
[{ text: t( "OK" ) }],
{
cancelable: true
}
);
const scrollToInput = node => {
keyboardScrollRef?.current?.scrollToFocusedInput( node );
};
const toggleCheckBoxValue = checkbox => {
if ( checkBoxValue === checkbox ) {
setCheckBoxValue( "none" );
} else { setCheckBoxValue( checkbox ); }
};
const resetFlagModal = () => {
setCheckBoxValue( "none" );
setExplanation( "" );
closeFlagItemModal();
setLoading( false );
};
const createFlagMutation = useAuthenticatedMutation(
( params, optsWithAuth ) => createFlag( params, optsWithAuth ),
{
onSuccess: data => {
resetFlagModal();
onItemFlagged( data );
},
onError: error => {
setLoading( false );
showErrorAlert( error );
}
}
);
const submitFlag = () => {
if ( checkBoxValue !== "none" ) {
let params = {
flag: {
flaggable_type: itemType,
flaggable_id: id,
flag: checkBoxValue
}
};
if ( checkBoxValue === "other" ) {
params = { ...params, flag_explanation: explanation };
}
setLoading( true );
createFlagMutation.mutate( params );
}
};
return (
<Modal
visible={showFlagItemModal}
animationType="slide"
className="flex-1"
testID="FlagItemModal"
>
<SafeAreaView className="flex-1">
<View className="flex-row-reverse justify-between p-6 border-b">
<IconMaterial name="close" onPress={closeFlagItemModal} size={30} />
<Text className="text-xl">
{t( "Flag-An-Item" )}
</Text>
</View>
<KeyboardAwareScrollView
ref={keyboardScrollRef}
enableOnAndroid
enableAutomaticScroll
extraHeight={200}
className="p-6"
>
<Text className="text-base">
{t( "Flag-Item-Description" )}
</Text>
<View className="flex-row my-2">
<CheckBox
disabled={false}
value={checkBoxValue === "spam"}
onValueChange={() => toggleCheckBoxValue( "spam" )}
/>
<Text
className="font-bold text-lg ml-5"
onPress={() => toggleCheckBoxValue( "spam" )}
>
{t( "Spam" )}
</Text>
</View>
<Text className="mb-2 text-base" style>{t( "Spam-Examples" )}</Text>
<View className="flex-row my-2">
<CheckBox
disabled={false}
value={checkBoxValue === "inappropriate"}
onValueChange={() => toggleCheckBoxValue( "inappropriate" )}
/>
<Text
className="font-bold text-lg ml-5"
onPress={() => toggleCheckBoxValue( "inappropriate" )}
>
{t( "Offensive-Inappropriate" )}
</Text>
</View>
<Text className="mb-2 text-base">{t( "Offensive-Inappropriate-Examples" )}</Text>
<View className="flex-row my-2">
<CheckBox
disabled={false}
value={checkBoxValue === "other"}
onValueChange={() => toggleCheckBoxValue( "other" )}
/>
<Text
className="font-bold text-lg ml-5"
onPress={() => toggleCheckBoxValue( "other" )}
>
{t( "Other" )}
</Text>
</View>
<Text className="mb-2 text-base">{t( "Flag-Item-Other-Description" )}</Text>
{( checkBoxValue === "other" )
&& (
<>
<TextInput
className="text-sm"
placeholder={t( "Flag-Item-Other-Input-Hint" )}
value={explanation}
onChangeText={text => setExplanation( text )}
onFocus={e => scrollToInput( findNodeHandle( e.target ) )}
/>
<Text>{`${explanation.length}/255`}</Text>
</>
)}
<View className="flex-row justify-center m-4">
<Button
className="rounded m-2"
text={t( "Cancel" )}
onPress={() => resetFlagModal()}
/>
<Button
className="rounded m-2"
text={t( "Save" )}
onPress={submitFlag}
level="primary"
loading={loading}
/>
</View>
</KeyboardAwareScrollView>
</SafeAreaView>
</Modal>
);
};
export default FlagItemModal;

View File

@@ -169,6 +169,16 @@ const ObsDetails = ( ): Node => {
} );
};
// reload if change to observation
useEffect( () => {
if ( localObservation && remoteObservation ) {
const remoteUpdatedAt = new Date( remoteObservation?.updated_at );
if ( remoteUpdatedAt > localObservation?.updated_at ) {
Observation.upsertRemoteObservations( [remoteObservation], realm );
}
}
}, [localObservation, remoteObservation, realm] );
useEffect( ( ) => {
if ( localObservation && !localObservation.viewed && !markViewedMutation.isLoading ) {
markViewedMutation.mutate( { id: uuid } );

View File

@@ -158,6 +158,14 @@ Finish = Finish
Fish = Fish
Flag-An-Item = Flag An Item
Flag-Item-Description = Flagging brings something to the attention of volunteer site curators. Please don't flag problems you can address with identifications, the Data Quality Assessment, or by talking to the person who made the content.
Flag-Item-Other-Description = Some other reason you can explain below.
Flag-Item-Other-Input-Hint = Specify the reason you're flagging this item
Following = Following
# Forgot password link
@@ -329,6 +337,10 @@ Observation-Attribution = Observation © {$attribution} · {$licenseCode}
Observations = Observations
Offensive-Inappropriate = Offensive/Inappropriate
Offensive-Inappropriate-Examples = Misleading or illegal content, racial or ethnic slurs, etc. For more on our defintion of "appropriate," see the FAQ.
Open = Open
# Picker prompt on observation edit
@@ -521,6 +533,10 @@ Sort-By = Sort By
Sort-by = Sort by
Spam = Spam
Spam-Examples = Commercial solicitation, links to nowhere, etc.
Species = Species
Status = Status

View File

@@ -105,6 +105,10 @@
"Filters": "Filters",
"Finish": "Finish",
"Fish": "Fish",
"Flag-An-Item": "Flag An Item",
"Flag-Item-Description": "Flagging brings something to the attention of volunteer site curators. Please don't flag problems you can address with identifications, the Data Quality Assessment, or by talking to the person who made the content.",
"Flag-Item-Other-Description": "Some other reason you can explain below.",
"Flag-Item-Other-Input-Hint": "Specify the reason you're flagging this item",
"Following": "Following",
"Forgot-Password": {
"comment": "Forgot password link",
@@ -232,6 +236,8 @@
"Observation": "Observation",
"Observation-Attribution": "Observation © { $attribution } · { $licenseCode }",
"Observations": "Observations",
"Offensive-Inappropriate": "Offensive/Inappropriate",
"Offensive-Inappropriate-Examples": "Misleading or illegal content, racial or ethnic slurs, etc. For more on our defintion of \"appropriate,\" see the FAQ.",
"Open": "Open",
"Organism-is-wild": {
"comment": "Picker prompt on observation edit",
@@ -353,6 +359,8 @@
},
"Sort-By": "Sort By",
"Sort-by": "Sort by",
"Spam": "Spam",
"Spam-Examples": "Commercial solicitation, links to nowhere, etc.",
"Species": "Species",
"Status": "Status",
"STATUS-header": {

View File

@@ -158,6 +158,14 @@ Finish = Finish
Fish = Fish
Flag-An-Item = Flag An Item
Flag-Item-Description = Flagging brings something to the attention of volunteer site curators. Please don't flag problems you can address with identifications, the Data Quality Assessment, or by talking to the person who made the content.
Flag-Item-Other-Description = Some other reason you can explain below.
Flag-Item-Other-Input-Hint = Specify the reason you're flagging this item
Following = Following
# Forgot password link
@@ -329,6 +337,10 @@ Observation-Attribution = Observation © {$attribution} · {$licenseCode}
Observations = Observations
Offensive-Inappropriate = Offensive/Inappropriate
Offensive-Inappropriate-Examples = Misleading or illegal content, racial or ethnic slurs, etc. For more on our defintion of "appropriate," see the FAQ.
Open = Open
# Picker prompt on observation edit
@@ -521,6 +533,10 @@ Sort-By = Sort By
Sort-by = Sort by
Spam = Spam
Spam-Examples = Commercial solicitation, links to nowhere, etc.
Species = Species
Status = Status

View File

@@ -1,5 +1,6 @@
import { Realm } from "@realm/react";
import Flag from "./Flag";
import User from "./User";
class Comment extends Realm.Object {
@@ -7,6 +8,7 @@ class Comment extends Realm.Object {
uuid: true,
body: true,
created_at: true,
flags: Flag.FLAG_FIELDS,
id: true,
user: User && User.USER_FIELDS
};
@@ -31,6 +33,7 @@ class Comment extends Realm.Object {
uuid: "string",
body: "string?",
created_at: { type: "string?", mapTo: "createdAt" },
flags: "Flag[]",
id: "int?",
user: "User?",
// this creates an inverse relationship so comments

39
src/realmModels/Flag.js Normal file
View File

@@ -0,0 +1,39 @@
import { Realm } from "@realm/react";
// import User from "./User";
class Flag extends Realm.Object {
static FLAG_FIELDS = {
id: true,
comment: true,
created_at: true,
flag: true,
flaggable_content: true,
flaggable_id: true,
flaggable_type: true,
resolved: true,
uuid: true
};
static mapApiToRealm( flag ) {
return flag;
}
static schema = {
name: "Flag",
primaryKey: "uuid",
properties: {
created_at: { type: "string?", mapTo: "createdAt" },
id: "int",
comment: "string?",
flag: "string",
flaggable_content: "string?",
flaggable_id: "int?",
flaggable_type: "string?",
resolved: "bool",
uuid: "string?"
}
}
}
export default Flag;

View File

@@ -1,5 +1,6 @@
import { Realm } from "@realm/react";
import Flag from "./Flag";
import Taxon from "./Taxon";
import User from "./User";
@@ -10,6 +11,8 @@ class Identification extends Realm.Object {
created_at: true,
current: true,
disagreement: true,
id: true,
flags: Flag.FLAG_FIELDS,
taxon: Taxon.TAXON_FIELDS,
updated_at: true,
// $FlowFixMe
@@ -22,6 +25,7 @@ class Identification extends Realm.Object {
return {
...id,
createdAt: id.created_at,
flags: id.flags.length > 0 ? Flag.mapApiToRealm( id.flags ) : [],
taxon: Taxon.mapApiToRealm( id.taxon ),
user: User.mapApiToRealm( id.user )
};
@@ -30,6 +34,7 @@ class Identification extends Realm.Object {
static mapApiToRealm( id, realm ) {
const newId = {
...id,
flags: Flag.mapApiToRealm( id.flags ),
taxon: Taxon.mapApiToRealm( id.taxon ),
user: User.mapApiToRealm( id.user, realm )
};
@@ -44,6 +49,8 @@ class Identification extends Realm.Object {
body: "string?",
category: "string?",
created_at: { type: "string?", mapTo: "createdAt" },
flags: "Flag[]",
id: "int?",
taxon: "Taxon?",
user: "User?",
vision: "bool?",

View File

@@ -36,7 +36,8 @@ class Observation extends Realm.Object {
quality_grade: true,
taxon: Taxon.TAXON_FIELDS,
time_observed_at: true,
user: User && User.USER_FIELDS
user: User && User.USER_FIELDS,
updated_at: true
}
static async new( obs ) {
@@ -422,6 +423,7 @@ class Observation extends Realm.Object {
// only by changing observed_on_string
time_observed_at: { type: "string?", mapTo: "timeObservedAt" },
user: "User?",
updated_at: "date?",
viewed: "bool?"
}
}

View File

@@ -2,6 +2,7 @@ import RNFS from "react-native-fs";
import Application from "./Application";
import Comment from "./Comment";
import Flag from "./Flag";
import Identification from "./Identification";
import Observation from "./Observation";
import ObservationPhoto from "./ObservationPhoto";
@@ -14,6 +15,7 @@ export default {
schema: [
Application,
Comment,
Flag,
Identification,
Observation,
ObservationPhoto,
@@ -22,9 +24,19 @@ export default {
Taxon,
User
],
schemaVersion: 31,
schemaVersion: 32,
path: `${RNFS.DocumentDirectoryPath}/db.realm`,
migration: ( oldRealm, newRealm ) => {
if ( oldRealm.schemaVersion < 32 ) {
const oldObservations = oldRealm.objects( "Observation" );
const newObservations = newRealm.objects( "Observation" );
oldObservations.keys( ).forEach( objectIndex => {
const oldObservation = oldObservations[objectIndex];
const newObservation = newObservations[objectIndex];
newObservation.updated_at = oldObservation.created_at;
} );
}
// Apparently you need to migrate when making a property optional
if ( oldRealm.schemaVersion < 31 ) {
const oldTaxa = oldRealm.objects( "Taxon" );
@@ -55,6 +67,7 @@ export default {
if ( oldRealm.schemaVersion < 29 ) {
const oldTaxa = oldRealm.objects( "Taxon" );
const newTaxa = newRealm.objects( "Taxon" );
// loop through all objects and set the new property in the new schema
oldTaxa.keys( ).forEach( objectIndex => {
const oldTaxon = oldTaxa[objectIndex];

View File

@@ -85,7 +85,9 @@ module.exports = {
buttonPrimaryDisabled: PRIMARY_DISABLED,
buttonWarningDisabled: WARNING_DISABLED,
buttonNeutralDisabled: NEUTRAL_DISABLED,
selectionGreen: "#C1FF00"
selectionGreen: "#C1FF00",
flaggedBackground: "#fcf8e3",
flaggedText: "#8a6d3a"
}
},
plugins: []

View File

@@ -0,0 +1,121 @@
import { fireEvent, screen } from "@testing-library/react-native";
import ActivityItem from "components/ObsDetails/ActivityItem";
import FlagItemModal from "components/ObsDetails/FlagItemModal";
import React from "react";
import { Provider as PaperProvider } from "react-native-paper";
import factory from "../../../factory";
import { renderComponent } from "../../../helpers/render";
jest.useFakeTimers( );
const mockCallback = jest.fn();
const mockObservation = factory( "LocalObservation", {
created_at: "2022-11-27T19:07:41-08:00",
time_observed_at: "2023-12-14T21:07:41-09:30"
} );
const mockIdentification = factory( "RemoteIdentification" );
jest.mock( "sharedHooks/useIsConnected" );
jest.mock( "sharedHelpers/dateAndTime", ( ) => ( {
__esModule: true,
formatIdDate: jest.fn()
} ) );
jest.mock( "providers/contexts", ( ) => ( {
__esModule: true,
RealmContext: {
useRealm: jest.fn()
}
} ) );
// TODO if/when we test mutation behavior, the mutation will need to be mocked
// so it actually does something, or we need to take a different approach
const mockMutate = jest.fn();
jest.mock( "sharedHooks/useAuthenticatedMutation", ( ) => ( {
__esModule: true,
default: ( ) => ( {
mutate: mockMutate
} )
} ) );
jest.mock( "../../../../src/components/LoginSignUp/AuthenticationService", ( ) => ( {
getUserId: ( ) => mockObservation.user.id,
isCurrentUser: ( ) => false
} ) );
jest.mock( "react-native-keyboard-aware-scroll-view", () => {
const KeyboardAwareScrollView = require( "react-native" ).ScrollView;
return { KeyboardAwareScrollView };
} );
test( "renders activity item with Flag Button", async ( ) => {
renderComponent(
<PaperProvider>
<ActivityItem item={mockIdentification} />
</PaperProvider>
);
expect( await screen.findByTestId( "KebabMenu.Button" ) ).toBeTruthy( );
expect( await screen.findByTestId( "FlagItemModal" ) ).toBeTruthy();
expect( await screen.findByTestId( "FlagItemModal" ) ).toHaveProperty( "props.visible", false );
fireEvent.press( await screen.findByTestId( "KebabMenu.Button" ) );
expect( screen.getByTestId( "MenuItem.Flag" ) ).toBeTruthy( );
expect( screen.getByText( "Flag" ) ).toBeTruthy( );
} );
test( "renders Flag Modal when Flag button pressed", async ( ) => {
renderComponent(
<PaperProvider>
<ActivityItem item={mockIdentification} />
</PaperProvider>
);
expect( await screen.findByTestId( "KebabMenu.Button" ) ).toBeTruthy( );
expect( await screen.findByTestId( "FlagItemModal" ) ).toBeTruthy();
expect( await screen.findByTestId( "FlagItemModal" ) ).toHaveProperty( "props.visible", false );
fireEvent.press( await screen.findByTestId( "KebabMenu.Button" ) );
expect( await screen.findByTestId( "MenuItem.Flag" ) ).toBeTruthy( );
fireEvent.press( await screen.findByTestId( "MenuItem.Flag" ) );
expect( screen.queryByTestId( "FlagItemModal" ) ).toHaveProperty( "props.visible", true );
expect( screen.getByText( "Flag An Item" ) ).toBeTruthy( );
} );
test( "renders Flag Modal content", async ( ) => {
renderComponent(
<FlagItemModal
id="000"
itemType="foo"
showFlagItemModal
closeFlagItemModal={mockCallback}
onItemFlagged={mockCallback}
/>
);
expect( screen.getByText( "Flag An Item" ) ).toBeTruthy( );
expect( screen.getByText( "Spam" ) ).toBeTruthy( );
expect( screen.getByText( "Offensive/Inappropriate" ) ).toBeTruthy( );
expect( screen.getByText( "Other" ) ).toBeTruthy( );
expect( screen.getAllByRole( "checkbox" ) ).toHaveLength( 3 );
} );
test( "calls flag api when save button pressed", async ( ) => {
renderComponent(
<FlagItemModal
id="000"
itemType="foo"
showFlagItemModal
closeFlagItemModal={mockCallback}
onItemFlagged={mockCallback}
/>
);
expect( screen.getByText( "Flag An Item" ) ).toBeTruthy( );
expect( screen.getByText( "Spam" ) ).toBeTruthy( );
expect( screen.queryAllByRole( "checkbox" ) ).toHaveLength( 3 );
fireEvent.press( screen.queryByText( "Spam" ) );
expect( screen.getByText( "Save" ) ).toBeTruthy( );
fireEvent.press( screen.queryByText( "Save" ) );
expect( await mockMutate ).toHaveBeenCalled();
} );