How to destroy google map on demand rides and deliveries in React - reactjs

I was following the documentation to implement google map on demand rides and deliveries solution (ODRD) here.
And my Map component in React:
const MapComponent = ({ styles }) => {
const ref = useRef(null);
const tripId = useRef<string>('');
const locationProvider =
useRef<google.maps.journeySharing.FleetEngineTripLocationProvider>();
const [error, setError] = useState<string | undefined>();
const mapOptions = useRef<MapOptionsModel>({
showAnticipatedRoutePolyline: true,
showTakenRoutePolyline: true,
destinationMarker: ICON_OPTIONS.USE_DEFAULT,
vehicleMarker: ICON_OPTIONS.USE_DEFAULT,
});
const [trip, setTrip] = useState<TripModel>({
status: null,
dropOff: null,
waypoints: null,
});
const setTripId = (newTripId: string) => {
tripId.current = newTripId;
if (locationProvider.current) locationProvider.current.tripId = newTripId;
};
const setMapOptions = (newMapOptions: MapOptionsModel) => {
mapOptions.current.showAnticipatedRoutePolyline =
newMapOptions.showAnticipatedRoutePolyline;
mapOptions.current.showTakenRoutePolyline =
newMapOptions.showTakenRoutePolyline;
mapOptions.current.destinationMarker = newMapOptions.destinationMarker;
mapOptions.current.vehicleMarker = newMapOptions.vehicleMarker;
setTripId(tripId.current);
};
const authTokenFetcher = async () => {
const response = await fetch(
`${PROVIDER_URL}/token/consumer/${tripId.current}`
);
const responseJson = await response.json();
return {
token: responseJson.jwt,
expiresInSeconds: 3300,
};
};
useEffect(() => {
locationProvider.current =
new google.maps.journeySharing.FleetEngineTripLocationProvider({
projectId: PROVIDER_PROJECT_ID,
authTokenFetcher,
tripId: tripId.current,
pollingIntervalMillis: DEFAULT_POLLING_INTERVAL_MS,
});
locationProvider.current.addListener(
'error',
(e: google.maps.ErrorEvent) => {
setError(e.error.message);
}
);
locationProvider.current.addListener(
'update',
(
e: google.maps.journeySharing.FleetEngineTripLocationProviderUpdateEvent
) => {
if (e.trip) {
setTrip({
status: e.trip.status,
dropOff: e.trip.dropOffTime,
waypoints: e.trip.remainingWaypoints,
});
setError(undefined);
}
}
);
const mapViewOptions: google.maps.journeySharing.JourneySharingMapViewOptions =
{
element: ref.current as unknown as Element,
locationProvider: locationProvider.current,
anticipatedRoutePolylineSetup: ({ defaultPolylineOptions }) => {
return {
polylineOptions: defaultPolylineOptions,
visible: mapOptions.current.showAnticipatedRoutePolyline,
};
},
takenRoutePolylineSetup: ({ defaultPolylineOptions }) => {
return {
polylineOptions: defaultPolylineOptions,
visible: mapOptions.current.showTakenRoutePolyline,
};
},
destinationMarkerSetup: ({ defaultMarkerOptions }) => {
if (
mapOptions.current.destinationMarker !== ICON_OPTIONS.USE_DEFAULT
) {
defaultMarkerOptions.icon =
mapOptions.current.destinationMarker.icon;
}
return { markerOptions: defaultMarkerOptions };
},
vehicleMarkerSetup: ({ defaultMarkerOptions }) => {
if (mapOptions.current.vehicleMarker !== ICON_OPTIONS.USE_DEFAULT) {
// Preserve some default icon properties.
if (defaultMarkerOptions.icon) {
defaultMarkerOptions.icon = Object.assign(
defaultMarkerOptions.icon,
mapOptions.current.vehicleMarker.icon
);
}
}
return { markerOptions: defaultMarkerOptions };
},
};
const mapView = new google.maps.journeySharing.JourneySharingMapView(
mapViewOptions
);
// Provide default zoom & center so the map loads even if trip ID is bad or stale.
mapView.map.setOptions(DEFAULT_MAP_OPTIONS);
}, []);
return (
<div style={styles.map} ref={ref} />
);
};
And my App component like this:
import React from 'react';
import { Wrapper, Status } from '#googlemaps/react-wrapper';
import MapComponent from './src/components/MapComponent';
import { API_KEY } from './src/utils/consts';
const render = (status: Status) => <Text>{status}</Text>;
const App = () => {
return (
<Wrapper
apiKey={API_KEY}
render={render}
version={'beta'}
// #ts-ignore
libraries={['journeySharing']}
>
<MapComponent />
</Wrapper>
);
};
Everything will works fine but I do not know how to destroy the map when component unmount in React. That's why my App always call API update the trip info.
I was tried to use clean up function in useEffect:
useEffect(() => {
locationProvider.current =
new google.maps.journeySharing.FleetEngineTripLocationProvider({
projectId: PROVIDER_PROJECT_ID,
authTokenFetcher,
tripId: tripId.current,
pollingIntervalMillis: DEFAULT_POLLING_INTERVAL_MS,
});
locationProvider.current.addListener(
'error',
(e: google.maps.ErrorEvent) => {
setError(e.error.message);
}
);
const updateEvent = locationProvider.current.addListener(
'update',
(
e: google.maps.journeySharing.FleetEngineTripLocationProviderUpdateEvent
) => {
if (e.trip) {
setTrip({
status: e.trip.status,
dropOff: e.trip.dropOffTime,
waypoints: e.trip.remainingWaypoints,
});
setError(undefined);
}
}
);
const mapViewOptions: google.maps.journeySharing.JourneySharingMapViewOptions =
{
element: ref.current as unknown as Element,
locationProvider: locationProvider.current,
anticipatedRoutePolylineSetup: ({ defaultPolylineOptions }) => {
return {
polylineOptions: defaultPolylineOptions,
visible: mapOptions.current.showAnticipatedRoutePolyline,
};
},
takenRoutePolylineSetup: ({ defaultPolylineOptions }) => {
return {
polylineOptions: defaultPolylineOptions,
visible: mapOptions.current.showTakenRoutePolyline,
};
},
destinationMarkerSetup: ({ defaultMarkerOptions }) => {
if (
mapOptions.current.destinationMarker !== ICON_OPTIONS.USE_DEFAULT
) {
defaultMarkerOptions.icon =
mapOptions.current.destinationMarker.icon;
}
return { markerOptions: defaultMarkerOptions };
},
vehicleMarkerSetup: ({ defaultMarkerOptions }) => {
if (mapOptions.current.vehicleMarker !== ICON_OPTIONS.USE_DEFAULT) {
// Preserve some default icon properties.
if (defaultMarkerOptions.icon) {
defaultMarkerOptions.icon = Object.assign(
defaultMarkerOptions.icon,
mapOptions.current.vehicleMarker.icon
);
}
}
return { markerOptions: defaultMarkerOptions };
},
};
const mapView = new google.maps.journeySharing.JourneySharingMapView(
mapViewOptions
);
// Provide default zoom & center so the map loads even if trip ID is bad or stale.
mapView.map.setOptions(DEFAULT_MAP_OPTIONS);
return () => {
mapView.map = null // or mapView.map.setmap(null);
google.maps.event.removeListener(updateEvent);
};
}, []);
But it was not working. Hope anyone can help me find out this. Thanks

Related

ag-grid keyboard navigation for frameworkComponents is not working

I'm trying to enable the keyboard action for my ag-grid.
I have few icons in my ag-grid I'm trying to click the icons through keyboard navigation.
to trigger the event via keyboard navigation I used onCellKeyPress where the user clicks enter key I'll trigger the event.
const onCellKeyPress = useCallback(
(keyPressEvent) => {
if (!isNil(keyPressEvent)) {
const {
event: { key },
colDef: { cellRenderer },
data: { indexId },
} = keyPressEvent;
if (key === ENTER_KEY) {
if (cellRenderer === 'deleteButtonSchemaRenderer') {
deleteRef?.current?.handleClickDeleteRow();
}
}
}
}
,[deleteRef?.current?.handleClickDeleteRow])
in framework component i just import my delete icon react component where i'm just doing the click event.
framework component
const frameworkComponents = useMemo(
() => ({
customNoRowsOverlay: ({
message,
iconName,
className,
}: ISystemNotification) => (
<SystemNotification
message={message}
iconName={iconName}
className={className}
/>
),
deleteButtonRenderer: (props: IDeleteRowButton) => {
const {
node: {
data: { id },
},
} = props;
return (
<DeleteRowButton
{...props}
setIsRowDeleting={setIsRowDeleting}
isDisabled={typeof id !== 'string'}
ref={deleteRef}
/>
);
},
}),
[ setIsRowDeleting]
);
deleterow component
const DeleteRowButton = forwardRef(
(
{ setIsRowDeleting, node, isDisabled = false }: IDeleteRowButton,
ref?: Ref<IUseRef | MutableRefObject<HTMLElement | null>>
) => {
useImperativeHandle(ref, () => ({ handleClickDeleteRow }));
const isRowDeleting = pathOr(false, ['data', 'isDeleting'], node);
const [isDeleting, setIsDeleting] = useState<boolean>(isRowDeleting);
const { t } = useTranslation();
const isGridDataChanged = useSelector(selectIsGridDataChanged);
const onClick = useCallback(
({ node, setIsDeleting, isRowDeleting, setIsRowDeleting, data }) => {
if (!isNil(data)) {
node.data = data;
}
const rowData = node.data;
setIsDeleting(!isRowDeleting);
let updatedData = {};
if (rowData.isDeleting) {
updatedData = { ...rowData, isDeleting: false };
} else {
updatedData = { ...rowData, isDeleting: true };
}
node.setData(updatedData);
setIsRowDeleting(true);
},
[]
);
const handleDeleteRow = useMemo(
() =>
debounce(() => {
onClick({
node,
setIsDeleting,
isRowDeleting,
setIsRowDeleting,
data: null,
});
}, 0),
[isRowDeleting, node, onClick, setIsRowDeleting]
);
const handleClickDeleteRow = useCallback(
(rowData: RowNode) => {
onClick({
node,
setIsDeleting,
isRowDeleting,
setIsRowDeleting,
data: rowData,
});
},
[isRowDeleting, node, onClick, setIsRowDeleting]
);
// Setting row active if changes was canceled
useEffect(() => {
if (!isGridDataChanged && !isRowDeleting && isDeleting) {
setIsDeleting(false);
}
}, [isDeleting, isGridDataChanged, isRowDeleting]);
return (
<Icon
disabled={isDisabled}
onClick={handleDeleteRow}
data-testid={TestIds.DeleteButtonIcon}
icon='trash'
title={t('Delete')}
/>
);
}
);
here it's working fine when the user is clicking via mouse, I'm trying to integrate keyboard action. but it always updates the last row of the ag-grid. I don't know the reason.
can any one help me in this.

React mock asynchronous axios with jest doesn't work

I'm trying to test the component below using mock axios, however, it looks like the components are not rendered as expected, could someone help me on that? I have been stuck for quite a while. The component is fetching an api every 1 second.
const RealtimePrice = () => {
var [cryptoFeed, setCryptoFeed] = useState<cryptoFeed>([]);
var [currency, setCurrency] = useState(currencyList[0]);
var [cryptoSearch, setCryptoSearch] = useState("");
const url = `https://api.coingecko.com/api/v3/coins/markets?ids=${ids}&vs_currency=${currency}`;
const intervalRef = useRef<NodeJS.Timer>();
const onCurrencyChangeHandler = useCallback((newValue: string) => {
setCurrency(newValue);
}, []);
const onCryptoSearchChangeHandler = useCallback((newValue: string) => {
setCryptoSearch(newValue);
}, []);
useEffect(() => {
const getCryptoFeed = () => {
axios.get(url).then((response: any) => {
if (response.data) {
console.debug("The state is set");
setCryptoFeed(response.data);
} else {
console.debug("The state is not set");
setCryptoFeed([]);
}
});
};
getCryptoFeed();
intervalRef.current = setInterval(getCryptoFeed, 1000);
return () => {
clearInterval(intervalRef.current);
};
}, [url]);
const priceBlocks = cryptoFeed
.filter((crypto) =>
crypto.name.toLowerCase().includes(cryptoSearch.toLowerCase())
)
.map((crypto: any) => {
return (
<PriceBlock
key={crypto.id}
id={crypto.id}
name={crypto.name}
price={crypto.current_price}
volume={crypto.total_volume}
change={crypto.price_change_24h}
></PriceBlock>
);
});
return (
<div className={styles.container}>
<div className={styles["header-section"]}>
<h1>Cryptocurrency Realtime Price</h1>
<div className="input-group">
<Selectbox
onChange={onCurrencyChangeHandler}
defaultOption={currencyList[0]}
options={currencyList}
/>
<Inputbox
placeHolder="Enter crypto name"
onChange={onCryptoSearchChangeHandler}
/>
</div>
</div>
<div className={styles.priceblocks}>{priceBlocks}</div>
</div>
);
};
The test is the defined as the following, findByText gives error, it couldn't find the element.
import { render, screen } from "#testing-library/react";
import RealtimePrice from "../RealtimePrice";
describe("Realtime Price", () => {
it("should render the Bitcoin price block", async () => {
render(<RealtimePrice />);
const pb = await screen.findByText("Bitcoin");
expect(pb).toBeInTheDocument();
});
});
And in package.json I have set
"jest": {
"collectCoverageFrom": [
"src/**/*.{js,jsx,ts,tsx}"
],
"resetMocks": false
}
In src/mocks/axios.js
const mockGetResponse = [
{
id: "bitcoin",
name: "Bitcoin",
price: 20000,
volume: 12004041094,
change: -12241,
},
{
id: "solana",
name: "Solana",
price: 87,
volume: 200876648,
change: 122,
},
];
const mockResponse = {
get: jest.fn().mockResolvedValue(mockGetResponse),
};
export default mockResponse;
With our comments seems clear the issue is that mock is not returning a proper response.data (that's why u are setting an empty array as the state)
Try doing:
const mockResponse = {
get: jest.fn().mockResolvedValue({data: mockGetResponse}),
};

Context variable not updating in child component React Context

I have multiple child components (component generate through recursion) inside my main component. Now the problem is when I updated the context variable in the parent component the child component doesn't render the updated value
here is my context provider
function MainLayoutProvider({ children }) {
const [mainJson, setMainJson] = useState([
{
component:'section',
id:'1111',
content:null,
type:'section',
cmType:'normal',
class:'',
style:{},
props:{}
}
]);
return (
<MainLayout.Provider value={mainJson}>
<MainDispatchLayout.Provider value={setMainJson}>
{children}
</MainDispatchLayout.Provider>
</MainLayout.Provider>
);
}
Here I included it on my main component
function App() {
return (
<DndProvider backend={HTML5Backend}>
<MainLayoutProvider>
<PlayGround></PlayGround>
</MainLayoutProvider>
</DndProvider>
);
}
inside the PlayGround component, there is another component name DropSection, where I am updating the 'mainJson' value
function DropSection() {
const board = useContext(MainLayout);
const setBoard = useContext(MainDispatchLayout);
const [{ isOver }, drop] = useDrop(() => ({
accept: "image",
drop(item, monitor) {
const didDrop = monitor.didDrop();
if (!didDrop ) {
addItemToBoard(item.sectionName)
}
},
collect: (monitor) => ({
isOver: !!monitor.isOver(),
}),
}));
const addItemToBoard = (sectionName) => {
let newJson = {
component:sectionName,
id:IdGenerator(),
content:null,
type:'text',
cmType:'normal',
class:'',
style:{},
props:{}
}
setBoard((board) => [...board, newJson]);
};
return (
<div ref={drop}>
<h4 className="text-center">DropZone</h4>
{board.map((config,index) => <RenderCard key={config.id} config={config} />)}
</div>
);
}
but in the RenderCard component, the value of 'mainJson' is not updating or rendering, I am getting the old value which initializes in MainLayoutContext
function RenderCard({config}) {
const board =useContext(MainLayout);
const setBoard = useContext(MainDispatchLayout);
const [{ isOver }, drop] = useDrop(() => ({
accept: "image",
drop(item, monitor) {
const didDrop = monitor.didDrop();
if (!didDrop ) {
addItemToBoard(item.sectionName)
}
},
collect: (monitor) => ({
isOver: !!monitor.isOver(),
}),
}));
const addItemToBoard = async (sectionName) => {
let newJson = {
component:sectionName,
id:IdGenerator(),
content:null,
type:'text',
cmType:'normal',
class:'',
style:{},
props:{}
}
setBoard((board) => [...board, newJson]);
};
if(config.cmType == 'complex'){
return RenderComplexCard(config)
}
var configProperty = {
style: config.style,
id:config.id,
};
return React.createElement(
config.component,
configProperty,
config.content &&
( config.type == "section" && Array.isArray(config.content) ? config.content.map(c => <RenderCard key={c.id} config={c} />) : config.content )
);
}
I had similar issue and solved by using useRef hook, if you do not want to use useState.
Do not forget to reference the variable by using .current when accessing it.

How to test component using spyFn in React Jest

I want to test my component Actions when I pass actions to children. In a nutshell, every source like twitter or facebook has its own set of actions. I'd like to check that it is called or not using spy.
This is my Actions component
const targetMetric = 'account dropdown'
const availableActions = {
addQuery: {
facebook: '^facebook://page/',
twitter: true
},
exclude: {
blog: [
'^blog://user/eventregistry/',
'^eventregistry://user/'
],
news: [
'^news://user/eventregistry/',
'^eventregistry://user/'
],
twitter: true,
youtube: true
},
reportAsNews: {
youtube: true,
mastodon: true,
twitter: true
}
}
const requiredHandlers = {
exclude: [
'onExcludeProfile'
],
reportAsNews: [
'onReportAsNews'
]
}
class Actions extends React.PureComponent {
get actions() {
const { account, handlers } = this.props
const actions = {};
Object.keys(availableActions).forEach(key =>
actions[ key ] = false
)
Object.keys(actions).forEach(key => {
const value = (
!!account.uri
&&
availableActions[ key ][ account.type ]
)
if (!value) {
return
}
if (typeof value === 'boolean') {
actions[ key ] = value
return
}
if (typeof value === 'string') {
const re = new RegExp(value, 'i')
actions[ key ] = re.test(account.uri)
return
}
if (
typeof value === 'object'
&&
Array.isArray(value)
) {
actions[ key ] = value.some(v => {
const re = new RegExp(v, 'i')
return re.test(account.uri)
})
}
})
Object.keys(actions).forEach(key => {
if (!actions[ key ] || !requiredHandlers[ key ]) {
return
}
actions[ key ] = requiredHandlers[ key ].some(i => handlers[ i ])
})
if (actions.addQuery) {
actions.addQuery = Object
.keys(this.addQueryActions)
.some(key => this.addQueryActions[ key ])
}
return actions
}
get addQueryActions() {
const { availableLanguages = [], userIsAdmin } = this.context
const { caseItem, handlers } = this.props
const actions = {
addQueryToFilter: !caseItem.isPaused && !!handlers.onAddQuery,
addQueryToAccountList: userIsAdmin && !!handlers.onAddToAccountList
}
actions.addQueryToSearch = actions.addQueryToFilter && !!availableLanguages.length
return actions
}
get actor() {
return pick(
this.props.account,
[ 'uri', 'name', 'link' ]
)
}
onExclude = () => {
const { account, handlers, isCaseLocked } = this.props
if (isCaseLocked) {
return
}
handlers.onExcludeProfile(account)
}
onReportAsNews = () => this.props.handlers.onReportAsNews(this.actor)
onAddToAccountList = () => {
const { account, from, handlers } = this.props
handlers.onAddToAccountList(account, from)
}
onAddToQuery = type => ({ language } = {}) => {
const { account, caseItem, handlers } = this.props
const { id } = caseItem
const metrics = {
index: getId(),
language,
type
}
handlers.onAddQuery({
...metrics,
expression: this.expressionFromAccount(account),
hideSearch: true,
id,
})
return metrics
}
expressionFromAccount = account => ({
and: [
{ account }
]
})
trackExcludeEvent = () => {
const { account } = this.props
this.trackEvent(
events.excludeAccounts,
{
accountsAdded: 1,
source: account.type
}
)
}
trackCreateNewQueryEvent = ({ index, language, type }) => {
const eventNameMap = {
filters: events.createNewFilter,
queries: events.createNewSearch,
}
const metrics = {
queryId: index,
target: targetMetric
}
if (type === 'queries') {
metrics[ 'language' ] = language.toLowerCase()
}
this.trackEvent(
eventNameMap[ type ],
metrics
)
}
trackReportAsNewsEvent = () => (
this.trackEvent(
events.reportAsNews,
{ source: this.props.account.type }
)
)
trackEvent = (eventName, props = {}) => {
const { from, message } = this.props
eventTracker.track(
eventName,
{
...props,
from,
messageUri: message.uri
}
)
}
getLangMenuActions = ({ handlers, isCaseLocked }, { availableLanguages }) => {
if (
isCaseLocked
||
!availableLanguages
||
!handlers.onAddQuery
) {
return []
}
const onClick = compose(
this.trackCreateNewQueryEvent,
this.onAddToQuery('queries')
)
return availableLanguages.map(({ label, value: language }) => ({
handler: onClick.bind(this, { language }),
id: `add-account-to-search-lang-${language}`,
label
}))
}
getActions = () => {
const { isCaseLocked } = this.props
const { userIsAdmin } = this.context
const actions = []
if (this.actions.addQuery) {
const addQueryAction = {
id: 'add-account-to',
isInactive: isCaseLocked && !userIsAdmin,
label: i18n.t('SOURCES.DROPDOWN_ADD_TO'),
children: []
}
if (this.addQueryActions.addQueryToSearch) {
addQueryAction.children.push({
id: 'add-account-to-search',
isInactive: isCaseLocked,
label: i18n.t('SOURCES.DROPDOWN_NEW_SEARCH'),
children: this.getLangMenuActions(this.props, this.context)
})
}
if (this.addQueryActions.addQueryToFilter) {
addQueryAction.children.push({
handler: compose(
this.trackCreateNewQueryEvent,
this.onAddToQuery('filters')
),
id: 'add-account-to-filter',
isInactive: isCaseLocked,
label: i18n.t('SOURCES.DROPDOWN_NEW_FILTER')
})
}
if (this.addQueryActions.addQueryToAccountList) {
addQueryAction.children.push({
handler: this.onAddToAccountList,
id: 'add-account-to-account-list',
label: i18n.t('SOURCES.DROPDOWN_ACCOUNT_LIST')
})
}
actions.push(addQueryAction)
}
if (this.actions.reportAsNews) {
actions.push({
handler: compose(
this.onReportAsNews,
this.trackReportAsNewsEvent
),
id: 'report-as-news',
label: i18n.t('SOURCES.DROPDOWN_REPORT_AS_NEWS')
})
}
if (this.actions.exclude) {
actions.push({
handler: compose(
this.onExclude,
this.trackExcludeEvent
),
id: 'exclude-account',
isInactive: isCaseLocked,
label: i18n.t('SOURCES.DROPDOWN_EXCLUDE')
})
}
console.log(actions)
return actions
}
render() {
return this.props.children({
actions: this.getActions()
})
}
}
This is my test file
import expect from 'expect'
const injectActions = require('inject-loader!./actions')
const Actions = injectActions({
'cm/common/event-tracker': {
eventTracker: {
track: () => {},
clear: () => {}
},
events: {
createNewFilter: '...',
createNewSearch: '...',
excludeAccounts: '...',
reportAsNews: '...',
}
},
}).default
const handlers = {
onAddQuery: () => { },
onAddToAccountList: () => { },
onExcludeProfile: () => { },
onReportAsNews: () => { }
}
const testProps = {
twitter: {
account: {
name: 'Twitter account',
uri: 'twitter://status/12345',
type: 'twitter'
},
handlers,
},
facebookPage: {
account: {
name: 'Facebook page account',
uri: 'facebook://page/12345',
type: 'facebook'
},
handlers
}
}
describe('Actions component', () => {
let node
beforeEach(() => {
node = document.createElement('div')
})
afterEach(() => {
ReactDOM.unmountComponentAtNode(node)
})
it('returns empty actions array by default', () => {
const spyFn = expect.createSpy().andReturn(null)
ReactDOM.render(
<Actions>{spyFn}</Actions>,
node
)
expect(spyFn).toHaveBeenCalledWith({ actions: [] })
})
describe('Twitter', () => {
it('returns "Exclude" action', () => {
const { account, handlers } = testProps.twitter
const spyFn = expect.createSpy()
ReactDOM.render(
<Actions
account={account}
handlers={{
onExcludeProfile: handlers.onExcludeProfile
}}
isCaseLocked={false}
>
{spyFn}
</Actions>,
node
)
expect(spyFn).toHaveBeenCalledWith({ actions: [
{
handler: () => {},
id: 'exclude-account',
isInactive: false,
label: 'Exclude',
}
] })
})
})
First unit case works fine, but the second is wrong. Actually I don't need all object there too. I'd like to be only sure that it contains id: 'exclude-account' there.
Please guys about any help.
You can use expect.objectContaining(object)
matches any received object that recursively matches the expected properties
E.g.
describe('68770432', () => {
test('should pass', () => {
const spyFn = jest.fn();
spyFn({
actions: [{ handler: () => {}, id: 'exclude-account', isInactive: false, label: 'Exclude' }],
});
expect(spyFn).toBeCalledWith({
actions: [
expect.objectContaining({
id: 'exclude-account',
}),
],
});
});
});

React not working with memo and tree structure

I've been going at it for 2 days and cannot figure it out :(
I have a tree-like conversation as you can see in the screenshot.
When a person types something in an empty input field, the Message is then added to the reducer array "conversationsData.messages". When that happens, the Replies component of each Message is listening to changes to only the ~3 replies of that message. If the replies change, then the Replies should rerender. Buuut... The problem is that every single Reply component, and thus every single message is being re-rendered which is causing lag.
Can you please help me get the memo to work properly?
ConversationManager/Conversation/Message.tsx
import React, { FunctionComponent, ReactElement, useRef, useState, useEffect, useMemo } from 'react'
import { useDispatch, useStore, useSelector } from 'react-redux'
import Autosuggest, { OnSuggestionSelected, ChangeEvent } from 'react-autosuggest'
import colors from '#common/colors'
import { updateMessage, removeMessage } from '#reducers/conversationsData'
import usePrevious from 'react-hooks-use-previous'
import MessageInterface, { MessageInitState } from '#interfaces/message'
import { RootState } from '#reducers/rootReducer'
import NewReply from './NewReply'
import { StyleSheet } from '#interfaces/common'
interface IMessageProps {
origMessage: MessageInterface,
isSubClone: boolean,
firstRender: boolean, // It's firstRender=true if we're rendering the message for the first time, "false" if it's a dynamic render
isStarter?: boolean
}
const MessageFunc = ({ origMessage, isSubClone, firstRender }: IMessageProps): ReactElement | null => {
if(!origMessage.id){
return null
}
const dispatch = useDispatch()
const store = useStore()
const state: RootState = store.getState()
const [inputSuggestions, setInputSuggestions] = useState<MessageInterface[]>([])
const [inputWidth, setInputWidth] = useState(0)
const $invisibleInput = useRef<HTMLInputElement>(null)
const isFirstRun = useRef(true)
const [localMessage, setLocalMessage] = useState<MessageInterface>(MessageInitState)
const previousLocalMessage = usePrevious<MessageInterface>(localMessage, MessageInitState)
useEffect(() => {
isFirstRun.current = true
setLocalMessage(origMessage)
}, [origMessage])
useEffect(() => {
if(!localMessage.id) return
if(isFirstRun.current == true){
setupInputWidth()
isFirstRun.current = false
}
if(previousLocalMessage.text != localMessage.text){
setupInputWidth()
}
if(previousLocalMessage.cloneId != localMessage.cloneId){
setupIfMessageClone()
}
}, [localMessage])
const characterMessages = state.conversationsData.messages.filter((m) => {
return m.characterId == origMessage.characterId
})
const parent: MessageInterface = characterMessages.find((m) => {
return m.id == origMessage.parentId
}) || MessageInitState
const setupIfMessageClone = () => { // This function is only relevant if this message is a clone of another one
if(!localMessage.cloneId) return
const cloneOf = characterMessages.find((m) => {
return m.id == localMessage.cloneId
}) || MessageInitState
setLocalMessage({
...localMessage,
text: cloneOf.text
})
}
const setupInputWidth = () => {
let width = $invisibleInput.current ? $invisibleInput.current.offsetWidth : 0
width = width + 30 // Let's make the input width a bit bigger
setInputWidth(width)
}
const _onFocus = () => {
// if(!localMessage.text){ // No message text, create a new one
// dispatch(updateMessage(localMessage))
// }
}
const _onBlur = () => {
if(localMessage.text){
dispatch(updateMessage(localMessage))
}
// No message, delete it from reducer
else {
dispatch(removeMessage(localMessage))
}
}
const _onChange = (event: React.FormEvent, { newValue }: ChangeEvent): void => {
setLocalMessage({
...localMessage,
cloneId: '',
text: newValue
})
}
const _suggestionSelected: OnSuggestionSelected<MessageInterface> = (event, { suggestion }) => {
setLocalMessage({
...localMessage,
cloneId: suggestion.id
})
}
const getSuggestions = (value: string): MessageInterface[] => {
const inputVal = value.trim().toLowerCase()
const inputLen = inputVal.length
return inputLen === 0 ? [] : characterMessages.filter(message =>
message.text.toLowerCase().slice(0, inputLen) === inputVal
)
}
if(!localMessage.id){
return null
}
else {
return (
<>
<li>
<div>
<Autosuggest
suggestions={inputSuggestions}
onSuggestionsFetchRequested={({ value }) => setInputSuggestions(getSuggestions(value))}
onSuggestionsClearRequested={() => setInputSuggestions([])}
getSuggestionValue={(suggestion) => suggestion.text}
onSuggestionSelected={_suggestionSelected}
renderSuggestion={(suggestion) => (
<div>
{suggestion.text}
</div>
)}
theme={{ ...autoSuggestTheme, input: {
...styles.input,
width: inputWidth,
borderBottomColor: localMessage.cloneId ? colors.purple : 'default',
borderBottomWidth: localMessage.cloneId ? 2 : 1
} }}
inputProps={{
value: localMessage.text,
onChange: _onChange,
onBlur: _onBlur,
onFocus: _onFocus,
className: 'form-control',
disabled: isSubClone
}}
/>
<span style={styles.invisibleSpan} ref={$invisibleInput}>{localMessage.text}</span>
</div>
<ul className="layer">
<Replies parentMessage={localMessage} isSubClone={isSubClone} />
</ul>
</li>
</>
)
}
}
const Message = React.memo(MessageFunc)
// const Message = MessageFunc
interface IRepliesProps {
parentMessage: MessageInterface,
isSubClone: boolean
}
const RepliesFunc: FunctionComponent<IRepliesProps> = ({
parentMessage, isSubClone
}: IRepliesProps): ReactElement | null => {
const previousParentMessage = usePrevious<MessageInterface>(parentMessage, MessageInitState)
const isFirstRun = useRef(true)
const replies: MessageInterface[] = useSelector((state: RootState) => state.conversationsData.messages.filter((m) => {
// If parent is regular message
if(!parentMessage.cloneId){
return m.parentId == parentMessage.id && m.characterId == parentMessage.characterId
}
// If parent is a clone, then replies need to come from the main clone
// else {
// return m.parentId == parentMessage.cloneId
// }
}))
if(replies.length){
return (
<>
{console.log('rendering Replies...')}
{replies.map((reply) => {
return (
<Message
origMessage={reply}
key={reply.id}
isSubClone={parentMessage.cloneId ? true : isSubClone}
firstRender={true}
/>
)
})}
{parentMessage.text && !parentMessage.cloneId && !isSubClone && (
<NewReply
parentMessage={parentMessage}
/>
)}
</>
)
}
else {
return null
}
}
// const Replies = React.memo(RepliesFunc)
const Replies = RepliesFunc
export default Message
const styles: StyleSheet = {
input: {
width: 0,
padding: 0,
paddingLeft: 10,
lineHeight: 25,
height: 25,
fontSize: 11,
boxShadow: 'none',
minWidth: 22
},
clone: {
borderBottomWidth: 2,
borderBottomColor: colors.purple
},
invisibleSpan: { // This is used for getting text width of input (for dynamic resizing of input fields)
opacity: 0,
position: 'absolute',
left: -9999,
top: -9999,
fontSize: 11
}
}
const autoSuggestTheme: StyleSheet = {
container: {
position: 'relative'
},
inputOpen: {
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0
},
suggestionsContainer: {
display: 'none'
},
suggestionsContainerOpen: {
display: 'block',
position: 'absolute',
top: 25,
width: '100%',
minWidth: 400,
border: '1px solid #aaa',
backgroundColor: '#fff',
fontWeight: 300,
fontSize: 11,
borderBottomLeftRadius: 4,
borderBottomRightRadius: 4,
zIndex: 2
},
suggestionsList: {
margin: 0,
padding: 0,
listStyleType: 'none'
},
suggestion: {
cursor: 'pointer',
padding: '5px 10px'
},
suggestionHighlighted: {
backgroundColor: '#ddd'
}
}
reducers/ConversationsData.ts
import { createSlice, PayloadAction } from '#reduxjs/toolkit'
import MessageInterface from '#interfaces/message'
import axios, { AxiosRequestConfig } from 'axios'
import conversationsDataJSON from '#data/conversationsData.json'
import { AppThunk } from '#reducers/store'
import _ from 'lodash'
interface IInitialState {
loaded: boolean,
messages: MessageInterface[]
}
export const initialState: IInitialState = {
loaded: false,
messages: []
}
export const charactersDataSlice = createSlice({
name: 'conversationsData',
initialState,
reducers: {
loadData: (state, action: PayloadAction<MessageInterface[]>) => {
return state = {
loaded: true,
messages:action.payload
}
},
add: (state, { payload }: PayloadAction<{message: MessageInterface}>) => {
state.messages.push(payload.message)
},
edit: (state, { payload }: PayloadAction<{message: MessageInterface}>) => {
const updatedConversations = state.messages.map(message => {
if(message.id == payload.message.id && message.characterId == payload.message.characterId){
return message = {
...payload.message,
text: payload.message.cloneId ? '' : payload.message.text // If there's a cloneId, don't save the text since the text comes from the clone parent
}
}
else {
return message
}
})
state.messages = updatedConversations
},
remove: (state, { payload }: PayloadAction<{message: MessageInterface}>) => {
_.remove(state.messages, (message) => {
return message.id == payload.message.id && message.characterId == payload.message.characterId
})
}
}
})
const { actions, reducer } = charactersDataSlice
const { loadData, edit, add, remove } = actions
// Thunk actions
// ---------
const loadConversationsData = (): AppThunk => {
return dispatch => {
const conversationsData: MessageInterface[] = conversationsDataJSON
dispatch(loadData(conversationsData))
}
}
const updateMessage = (message: MessageInterface): AppThunk => {
return (dispatch, getState) => {
const existingMessage: MessageInterface | undefined = getState().conversationsData.messages.find((m: MessageInterface) => {
return m.id == message.id && m.characterId == message.characterId
})
// If message exists, update it
if(existingMessage){
dispatch(edit({
message: message
}))
}
// else create a new message
else {
dispatch(add({
message: message
}))
}
setTimeout(() => {
dispatch(saveConversationsData())
}, 10)
}
}
const removeMessage = (message: MessageInterface): AppThunk => {
return (dispatch, getState) => {
const children: MessageInterface[] | [] = getState().conversationsData.messages.filter((m: MessageInterface) => {
return m.parentId == message.id && m.characterId == message.characterId
})
const hasChildren = children.length > 0
// If message has children, stop
if(hasChildren){
alert('This message has children. Will not kill this message. Remove the children first.')
}
// Otherwise, go ahead and kill message
else {
dispatch(remove({
message: message
}))
setTimeout(() => {
dispatch(saveConversationsData())
}, 10)
}
}
}
export const saveConversationsData = (): AppThunk => {
return (dispatch, getState) => {
const conversationsMessages = getState().conversationsData.messages
const conversationsMessagesJSON = JSON.stringify(conversationsMessages, null, '\t')
const options: AxiosRequestConfig = {
method: 'POST',
url: 'http://localhost:8888/api/update-conversations.php',
headers: { 'content-type': 'application/json; charset=UTF-8' },
data: conversationsMessagesJSON
}
axios(options)
.catch(error => console.error('Saving conversationsData error:', error))
}
}
// Exporting it all
// ---------
export { loadConversationsData, updateMessage, removeMessage }
export default reducer
interfaces/message.ts
export default interface MessageInterface {
id: string,
characterId: string,
text: string,
cloneId: string,
parentId: string
}
export const MessageInitState: MessageInterface = {
id: '',
characterId: '',
text: '',
cloneId: '',
parentId: ''
}
Because your selector uses Array.prototype.filter you create a new array every time the messages array changes for each component.
If you would store the data in the state as nested data you can prevent this from happening. For example: {id:1, message:'hello', replies:[{id:2, message:'world', replies:[]}]}.
A simpler way is to use the memoization of reselect to see if each element in the filtered array is the same as it was last time. This will require more resources than the nested solution as it will perform the filter on every change for every branch but won't re render needlessly.
Here is the simple example:
const { Provider, useDispatch, useSelector } = ReactRedux;
const { createStore, applyMiddleware, compose } = Redux;
const { createSelector, defaultMemoize } = Reselect;
const initialState = { messages: [] };
//action types
const ADD = 'ADD';
//helper crating id for messages
const id = ((id) => () => ++id)(0);
//action creators
const add = (parentId, message) => ({
type: ADD,
payload: { parentId, message, id: id() },
});
const reducer = (state, { type, payload }) => {
if (type === ADD) {
const { parentId, message, id } = payload;
return {
...state,
messages: state.messages.concat({
id,
parentId,
message,
}),
};
}
return state;
};
//selectors
const selectMessages = (state) => state.messages;
//curry creating selector function that closes over message id
// https://github.com/amsterdamharu/selectors
const createSelectMessageById = (messageId) =>
createSelector([selectMessages], (messages) =>
messages.find(({ id }) => id === messageId)
);
//used to check each item in the array is same as last
// time the function was called
const createMemoizeArray = (array) => {
const memArray = defaultMemoize((...array) => array);
return (array) => memArray.apply(null, array);
};
//curry creating selector function that closes over parentId
// https://github.com/amsterdamharu/selectors
const createSelectMessagesByParentId = (parentId) => {
//memoizedArray([1,2,3]) === memoizedArray([1,2,3]) is true
//https://github.com/reduxjs/reselect/issues/451#issuecomment-637521511
const memoizedArray = createMemoizeArray();
return createSelector([selectMessages], (messages) =>
memoizedArray(
messages.filter((m) => m.parentId === parentId)
)
);
};
//creating store with redux dev tools
const composeEnhancers =
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
reducer,
initialState,
composeEnhancers(
applyMiddleware(() => (next) => (action) =>
next(action)
)
)
);
const AddMessage = ({ addMessage }) => {
const [reply, setReply] = React.useState('');
return (
<div>
<label>
message:
<input
type="text"
onChange={(e) => setReply(e.target.value)}
value={reply}
/>
</label>
<button onClick={() => addMessage(reply)}>Add</button>
</div>
);
};
const AddMessageContainer = React.memo(
function AddMessageContainer({ messageId }) {
const dispatch = useDispatch();
const addMessage = React.useCallback(
(message) => dispatch(add(messageId, message)),
//dispatch in deps should not be needed but
// my linter still complains about it
[dispatch, messageId]
);
return <AddMessage addMessage={addMessage} />;
}
);
const Message = ({ message, replies }) => {
console.log('in message render', message && message.message);
return (
<div>
{message ? <h1>{message.message}</h1> : ''}
{Boolean(replies.length) && (
<ul>
{replies.map(({ id }) => (
<MessageContainer key={id} messageId={id} />
))}
</ul>
)}
{/* too bad optional chaining (message?.id) does not work on SO */}
<AddMessageContainer
messageId={message && message.id}
/>
</div>
);
};
const MessageContainer = React.memo(
function MessageContainer({ messageId }) {
const selectMessage = React.useMemo(
() => createSelectMessageById(messageId),
[messageId]
);
const selectReplies = React.useMemo(
() => createSelectMessagesByParentId(messageId),
[messageId]
);
const message = useSelector(selectMessage);
const replies = useSelector(selectReplies);
return <Message message={message} replies={replies} />;
}
);
const App = () => {
return <MessageContainer />;
};
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux/4.0.5/redux.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/7.2.0/react-redux.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/reselect/4.0.0/reselect.min.js"></script>
<div id="root"></div>

Resources