Live location share - focus on user location on list item click (PSG-609) (#9051)

* extract preventDefaultWrapper into utils

* add click handling to beacon list item

* add click handling to dialog sidebar

* focus in on beacons when clicked in list

* stylelint

* fussy import ordering

* test beacon focusing in beaocnviewdialog
This commit is contained in:
Kerry 2022-07-18 10:34:39 +02:00 committed by GitHub
parent 38a913488f
commit dc6ceb1d1c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 473 additions and 89 deletions

View file

@ -22,6 +22,8 @@ limitations under the License.
padding: $spacing-12 0; padding: $spacing-12 0;
border-bottom: 1px solid $system; border-bottom: 1px solid $system;
cursor: pointer;
} }
.mx_BeaconListItem_avatarIcon { .mx_BeaconListItem_avatarIcon {
@ -61,3 +63,8 @@ limitations under the License.
color: $tertiary-content; color: $tertiary-content;
font-size: $font-10px; font-size: $font-10px;
} }
.mx_BeaconListItem_interactions {
display: flex;
flex-direction: row;
}

View file

@ -14,13 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { useContext } from 'react'; import React, { HTMLProps, useContext } from 'react';
import { Beacon, BeaconEvent } from 'matrix-js-sdk/src/matrix'; import { Beacon, BeaconEvent } from 'matrix-js-sdk/src/matrix';
import { LocationAssetType } from 'matrix-js-sdk/src/@types/location'; import { LocationAssetType } from 'matrix-js-sdk/src/@types/location';
import MatrixClientContext from '../../../contexts/MatrixClientContext'; import MatrixClientContext from '../../../contexts/MatrixClientContext';
import { useEventEmitterState } from '../../../hooks/useEventEmitter'; import { useEventEmitterState } from '../../../hooks/useEventEmitter';
import { humanizeTime } from '../../../utils/humanize'; import { humanizeTime } from '../../../utils/humanize';
import { preventDefaultWrapper } from '../../../utils/NativeEventUtils';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import MemberAvatar from '../avatars/MemberAvatar'; import MemberAvatar from '../avatars/MemberAvatar';
import BeaconStatus from './BeaconStatus'; import BeaconStatus from './BeaconStatus';
@ -32,7 +33,7 @@ interface Props {
beacon: Beacon; beacon: Beacon;
} }
const BeaconListItem: React.FC<Props> = ({ beacon }) => { const BeaconListItem: React.FC<Props & HTMLProps<HTMLLIElement>> = ({ beacon, ...rest }) => {
const latestLocationState = useEventEmitterState( const latestLocationState = useEventEmitterState(
beacon, beacon,
BeaconEvent.LocationUpdate, BeaconEvent.LocationUpdate,
@ -52,7 +53,7 @@ const BeaconListItem: React.FC<Props> = ({ beacon }) => {
const humanizedUpdateTime = humanizeTime(latestLocationState.timestamp); const humanizedUpdateTime = humanizeTime(latestLocationState.timestamp);
return <li className='mx_BeaconListItem'> return <li className='mx_BeaconListItem' {...rest}>
{ isSelfLocation ? { isSelfLocation ?
<MemberAvatar <MemberAvatar
className='mx_BeaconListItem_avatar' className='mx_BeaconListItem_avatar'
@ -69,7 +70,11 @@ const BeaconListItem: React.FC<Props> = ({ beacon }) => {
label={beaconMember?.name || beacon.beaconInfo.description || beacon.beaconInfoOwner} label={beaconMember?.name || beacon.beaconInfo.description || beacon.beaconInfoOwner}
displayStatus={BeaconDisplayStatus.Active} displayStatus={BeaconDisplayStatus.Active}
> >
<ShareLatestLocation latestLocationState={latestLocationState} /> { /* eat events from interactive share buttons
so parent click handlers are not triggered */ }
<div className='mx_BeaconListItem_interactions' onClick={preventDefaultWrapper(() => {})}>
<ShareLatestLocation latestLocationState={latestLocationState} />
</div>
</BeaconStatus> </BeaconStatus>
<span className='mx_BeaconListItem_lastUpdated'>{ _t("Updated %(humanizedUpdateTime)s", { humanizedUpdateTime }) }</span> <span className='mx_BeaconListItem_lastUpdated'>{ _t("Updated %(humanizedUpdateTime)s", { humanizedUpdateTime }) }</span>
</div> </div>

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { MatrixClient } from 'matrix-js-sdk/src/client'; import { MatrixClient } from 'matrix-js-sdk/src/client';
import { import {
Beacon, Beacon,
@ -45,7 +45,16 @@ interface IProps extends IDialogProps {
roomId: Room['roomId']; roomId: Room['roomId'];
matrixClient: MatrixClient; matrixClient: MatrixClient;
// open the map centered on this beacon's location // open the map centered on this beacon's location
focusBeacon?: Beacon; initialFocusedBeacon?: Beacon;
}
// track the 'focused time' as ts
// to make it possible to refocus the same beacon
// as the beacon location may change
// or the map may move around
interface FocusedBeaconState {
ts: number;
beacon?: Beacon;
} }
const getBoundsCenter = (bounds: Bounds): string | undefined => { const getBoundsCenter = (bounds: Bounds): string | undefined => {
@ -59,31 +68,52 @@ const getBoundsCenter = (bounds: Bounds): string | undefined => {
}); });
}; };
const useInitialMapPosition = (liveBeacons: Beacon[], focusBeacon?: Beacon): { const useInitialMapPosition = (liveBeacons: Beacon[], { beacon, ts }: FocusedBeaconState): {
bounds?: Bounds; centerGeoUri: string; bounds?: Bounds; centerGeoUri: string;
} => { } => {
const bounds = useRef<Bounds | undefined>(getBeaconBounds(liveBeacons)); const [bounds, setBounds] = useState<Bounds | undefined>(getBeaconBounds(liveBeacons));
const centerGeoUri = useRef<string>( const [centerGeoUri, setCenterGeoUri] = useState<string>(
focusBeacon?.latestLocationState?.uri || beacon?.latestLocationState?.uri ||
getBoundsCenter(bounds.current), getBoundsCenter(bounds),
); );
return { bounds: bounds.current, centerGeoUri: centerGeoUri.current };
useEffect(() => {
if (
// this check ignores the first initial focused beacon state
// as centering logic on map zooms to show everything
// instead of focusing down
ts !== 0 &&
// only set focus to a known location
beacon?.latestLocationState?.uri
) {
// append custom `mxTs` parameter to geoUri
// so map is triggered to refocus on this uri
// event if it was previously the center geouri
// but the map have moved/zoomed
setCenterGeoUri(`${beacon?.latestLocationState?.uri};mxTs=${Date.now()}`);
setBounds(getBeaconBounds([beacon]));
}
}, [beacon, ts]);
return { bounds, centerGeoUri };
}; };
/** /**
* Dialog to view live beacons maximised * Dialog to view live beacons maximised
*/ */
const BeaconViewDialog: React.FC<IProps> = ({ const BeaconViewDialog: React.FC<IProps> = ({
focusBeacon, initialFocusedBeacon,
roomId, roomId,
matrixClient, matrixClient,
onFinished, onFinished,
}) => { }) => {
const liveBeacons = useLiveBeacons(roomId, matrixClient); const liveBeacons = useLiveBeacons(roomId, matrixClient);
const [focusedBeaconState, setFocusedBeaconState] =
useState<FocusedBeaconState>({ beacon: initialFocusedBeacon, ts: 0 });
const [isSidebarOpen, setSidebarOpen] = useState(false); const [isSidebarOpen, setSidebarOpen] = useState(false);
const { bounds, centerGeoUri } = useInitialMapPosition(liveBeacons, focusBeacon); const { bounds, centerGeoUri } = useInitialMapPosition(liveBeacons, focusedBeaconState);
const [mapDisplayError, setMapDisplayError] = useState<Error>(); const [mapDisplayError, setMapDisplayError] = useState<Error>();
@ -94,6 +124,10 @@ const BeaconViewDialog: React.FC<IProps> = ({
} }
}, [mapDisplayError]); }, [mapDisplayError]);
const onBeaconListItemClick = (beacon: Beacon) => {
setFocusedBeaconState({ beacon, ts: Date.now() });
};
return ( return (
<BaseDialog <BaseDialog
className='mx_BeaconViewDialog' className='mx_BeaconViewDialog'
@ -144,7 +178,7 @@ const BeaconViewDialog: React.FC<IProps> = ({
</MapFallback> </MapFallback>
} }
{ isSidebarOpen ? { isSidebarOpen ?
<DialogSidebar beacons={liveBeacons} requestClose={() => setSidebarOpen(false)} /> : <DialogSidebar beacons={liveBeacons} onBeaconClick={onBeaconListItemClick} requestClose={() => setSidebarOpen(false)} /> :
<AccessibleButton <AccessibleButton
kind='primary' kind='primary'
onClick={() => setSidebarOpen(true)} onClick={() => setSidebarOpen(true)}

View file

@ -26,9 +26,14 @@ import BeaconListItem from './BeaconListItem';
interface Props { interface Props {
beacons: Beacon[]; beacons: Beacon[];
requestClose: () => void; requestClose: () => void;
onBeaconClick: (beacon: Beacon) => void;
} }
const DialogSidebar: React.FC<Props> = ({ beacons, requestClose }) => { const DialogSidebar: React.FC<Props> = ({
beacons,
onBeaconClick,
requestClose,
}) => {
return <div className='mx_DialogSidebar'> return <div className='mx_DialogSidebar'>
<div className='mx_DialogSidebar_header'> <div className='mx_DialogSidebar_header'>
<Heading size='h4'>{ _t('View List') }</Heading> <Heading size='h4'>{ _t('View List') }</Heading>
@ -36,13 +41,17 @@ const DialogSidebar: React.FC<Props> = ({ beacons, requestClose }) => {
className='mx_DialogSidebar_closeButton' className='mx_DialogSidebar_closeButton'
onClick={requestClose} onClick={requestClose}
title={_t('Close sidebar')} title={_t('Close sidebar')}
data-test-id='dialog-sidebar-close' data-testid='dialog-sidebar-close'
> >
<CloseIcon className='mx_DialogSidebar_closeButtonIcon' /> <CloseIcon className='mx_DialogSidebar_closeButtonIcon' />
</AccessibleButton> </AccessibleButton>
</div> </div>
<ol className='mx_DialogSidebar_list'> <ol className='mx_DialogSidebar_list'>
{ beacons.map((beacon) => <BeaconListItem key={beacon.identifier} beacon={beacon} />) } { beacons.map((beacon) => <BeaconListItem
key={beacon.identifier}
beacon={beacon}
onClick={() => onBeaconClick(beacon)}
/>) }
</ol> </ol>
</div>; </div>;
}; };

View file

@ -19,6 +19,7 @@ import React, { HTMLProps } from 'react';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { useOwnLiveBeacons } from '../../../utils/beacon'; import { useOwnLiveBeacons } from '../../../utils/beacon';
import { preventDefaultWrapper } from '../../../utils/NativeEventUtils';
import BeaconStatus from './BeaconStatus'; import BeaconStatus from './BeaconStatus';
import { BeaconDisplayStatus } from './displayStatus'; import { BeaconDisplayStatus } from './displayStatus';
import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton'; import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton';
@ -45,14 +46,6 @@ const OwnBeaconStatus: React.FC<Props & HTMLProps<HTMLDivElement>> = ({
onResetLocationPublishError, onResetLocationPublishError,
} = useOwnLiveBeacons([beacon?.identifier]); } = useOwnLiveBeacons([beacon?.identifier]);
// eat events here to avoid 1) the map and 2) reply or thread tiles
// moving under the beacon status on stop/retry click
const preventDefaultWrapper = (callback: () => void) => (e?: ButtonEvent) => {
e?.stopPropagation();
e?.preventDefault();
callback();
};
// combine display status with errors that only occur for user's own beacons // combine display status with errors that only occur for user's own beacons
const ownDisplayStatus = hasLocationPublishError || hasStopSharingError ? const ownDisplayStatus = hasLocationPublishError || hasStopSharingError ?
BeaconDisplayStatus.Error : BeaconDisplayStatus.Error :
@ -68,7 +61,9 @@ const OwnBeaconStatus: React.FC<Props & HTMLProps<HTMLDivElement>> = ({
{ ownDisplayStatus === BeaconDisplayStatus.Active && <AccessibleButton { ownDisplayStatus === BeaconDisplayStatus.Active && <AccessibleButton
data-test-id='beacon-status-stop-beacon' data-test-id='beacon-status-stop-beacon'
kind='link' kind='link'
onClick={preventDefaultWrapper(onStopSharing)} // eat events here to avoid 1) the map and 2) reply or thread tiles
// moving under the beacon status on stop/retry click
onClick={preventDefaultWrapper<ButtonEvent>(onStopSharing)}
className='mx_OwnBeaconStatus_button mx_OwnBeaconStatus_destructiveButton' className='mx_OwnBeaconStatus_button mx_OwnBeaconStatus_destructiveButton'
disabled={stoppingInProgress} disabled={stoppingInProgress}
> >
@ -78,6 +73,8 @@ const OwnBeaconStatus: React.FC<Props & HTMLProps<HTMLDivElement>> = ({
{ hasLocationPublishError && <AccessibleButton { hasLocationPublishError && <AccessibleButton
data-test-id='beacon-status-reset-wire-error' data-test-id='beacon-status-reset-wire-error'
kind='link' kind='link'
// eat events here to avoid 1) the map and 2) reply or thread tiles
// moving under the beacon status on stop/retry click
onClick={preventDefaultWrapper(onResetLocationPublishError)} onClick={preventDefaultWrapper(onResetLocationPublishError)}
className='mx_OwnBeaconStatus_button mx_OwnBeaconStatus_destructiveButton' className='mx_OwnBeaconStatus_button mx_OwnBeaconStatus_destructiveButton'
> >
@ -87,6 +84,8 @@ const OwnBeaconStatus: React.FC<Props & HTMLProps<HTMLDivElement>> = ({
{ hasStopSharingError && <AccessibleButton { hasStopSharingError && <AccessibleButton
data-test-id='beacon-status-stop-beacon-retry' data-test-id='beacon-status-stop-beacon-retry'
kind='link' kind='link'
// eat events here to avoid 1) the map and 2) reply or thread tiles
// moving under the beacon status on stop/retry click
onClick={preventDefaultWrapper(onStopSharing)} onClick={preventDefaultWrapper(onStopSharing)}
className='mx_OwnBeaconStatus_button mx_OwnBeaconStatus_destructiveButton' className='mx_OwnBeaconStatus_button mx_OwnBeaconStatus_destructiveButton'
> >

View file

@ -80,6 +80,13 @@ const useMapWithStyle = ({ id, centerGeoUri, onError, interactive, bounds }) =>
interface MapProps { interface MapProps {
id: string; id: string;
interactive?: boolean; interactive?: boolean;
/**
* set map center to geoUri coords
* Center will only be set to valid geoUri
* this prop is only simply diffed by useEffect, so to trigger *recentering* of the same geoUri
* append the uri with a var not used by the geoUri spec
* eg a timestamp: `geo:54,42;mxTs=123`
*/
centerGeoUri?: string; centerGeoUri?: string;
bounds?: Bounds; bounds?: Bounds;
className?: string; className?: string;

View file

@ -162,7 +162,7 @@ const MBeaconBody: React.FC<IBodyProps> = React.forwardRef(({ mxEvent, getRelati
{ {
roomId: mxEvent.getRoomId(), roomId: mxEvent.getRoomId(),
matrixClient, matrixClient,
focusBeacon: beacon, initialFocusedBeacon: beacon,
isMapDisplayError, isMapDisplayError,
}, },
"mx_BeaconViewDialog_wrapper", "mx_BeaconViewDialog_wrapper",

View file

@ -0,0 +1,25 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
// Wrap DOM event handlers with stopPropagation and preventDefault
export const preventDefaultWrapper =
<T extends React.BaseSyntheticEvent = React.BaseSyntheticEvent>(callback: () => void) => (e?: T) => {
e?.stopPropagation();
e?.preventDefault();
callback();
};

View file

@ -35,7 +35,7 @@ export const useMap = ({
interactive, interactive,
bodyId, bodyId,
onError, onError,
}: UseMapProps): MapLibreMap => { }: UseMapProps): MapLibreMap | undefined => {
const [map, setMap] = useState<MapLibreMap>(); const [map, setMap] = useState<MapLibreMap>();
useEffect( useEffect(

View file

@ -27,6 +27,7 @@ import { act } from 'react-dom/test-utils';
import BeaconListItem from '../../../../src/components/views/beacon/BeaconListItem'; import BeaconListItem from '../../../../src/components/views/beacon/BeaconListItem';
import MatrixClientContext from '../../../../src/contexts/MatrixClientContext'; import MatrixClientContext from '../../../../src/contexts/MatrixClientContext';
import { import {
findByTestId,
getMockClientWithEventEmitter, getMockClientWithEventEmitter,
makeBeaconEvent, makeBeaconEvent,
makeBeaconInfoEvent, makeBeaconInfoEvent,
@ -169,5 +170,30 @@ describe('<BeaconListItem />', () => {
expect(component.find('.mx_BeaconListItem_lastUpdated').text()).toEqual('Updated a few seconds ago'); expect(component.find('.mx_BeaconListItem_lastUpdated').text()).toEqual('Updated a few seconds ago');
}); });
}); });
describe('interactions', () => {
it('does not call onClick handler when clicking share button', () => {
const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]);
const onClick = jest.fn();
const component = getComponent({ beacon, onClick });
act(() => {
findByTestId(component, 'open-location-in-osm').at(0).simulate('click');
});
expect(onClick).not.toHaveBeenCalled();
});
it('calls onClick handler when clicking outside of share buttons', () => {
const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]);
const onClick = jest.fn();
const component = getComponent({ beacon, onClick });
act(() => {
// click the beacon name
component.find('.mx_BeaconStatus_description').simulate('click');
});
expect(onClick).toHaveBeenCalled();
});
});
}); });
}); });

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import { mount } from 'enzyme'; import { mount, ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { import {
MatrixClient, MatrixClient,
@ -28,15 +28,18 @@ import maplibregl from 'maplibre-gl';
import BeaconViewDialog from '../../../../src/components/views/beacon/BeaconViewDialog'; import BeaconViewDialog from '../../../../src/components/views/beacon/BeaconViewDialog';
import { import {
findByAttr,
findByTestId, findByTestId,
getMockClientWithEventEmitter, getMockClientWithEventEmitter,
makeBeaconEvent, makeBeaconEvent,
makeBeaconInfoEvent, makeBeaconInfoEvent,
makeRoomWithBeacons,
makeRoomWithStateEvents, makeRoomWithStateEvents,
} from '../../../test-utils'; } from '../../../test-utils';
import { TILE_SERVER_WK_KEY } from '../../../../src/utils/WellKnownUtils'; import { TILE_SERVER_WK_KEY } from '../../../../src/utils/WellKnownUtils';
import { OwnBeaconStore } from '../../../../src/stores/OwnBeaconStore'; import { OwnBeaconStore } from '../../../../src/stores/OwnBeaconStore';
import { BeaconDisplayStatus } from '../../../../src/components/views/beacon/displayStatus'; import { BeaconDisplayStatus } from '../../../../src/components/views/beacon/displayStatus';
import BeaconListItem from '../../../../src/components/views/beacon/BeaconListItem';
describe('<BeaconViewDialog />', () => { describe('<BeaconViewDialog />', () => {
// 14.03.2022 16:15 // 14.03.2022 16:15
@ -89,13 +92,18 @@ describe('<BeaconViewDialog />', () => {
const getComponent = (props = {}) => const getComponent = (props = {}) =>
mount(<BeaconViewDialog {...defaultProps} {...props} />); mount(<BeaconViewDialog {...defaultProps} {...props} />);
const openSidebar = (component: ReactWrapper) => act(() => {
findByTestId(component, 'beacon-view-dialog-open-sidebar').at(0).simulate('click');
component.setProps({});
});
beforeAll(() => { beforeAll(() => {
maplibregl.AttributionControl = jest.fn(); maplibregl.AttributionControl = jest.fn();
}); });
beforeEach(() => { beforeEach(() => {
jest.spyOn(OwnBeaconStore.instance, 'getLiveBeaconIds').mockRestore(); jest.spyOn(OwnBeaconStore.instance, 'getLiveBeaconIds').mockRestore();
jest.spyOn(global.Date, 'now').mockReturnValue(now);
jest.clearAllMocks(); jest.clearAllMocks();
}); });
@ -225,10 +233,7 @@ describe('<BeaconViewDialog />', () => {
beacon.addLocations([location1]); beacon.addLocations([location1]);
const component = getComponent(); const component = getComponent();
act(() => { openSidebar(component);
findByTestId(component, 'beacon-view-dialog-open-sidebar').at(0).simulate('click');
component.setProps({});
});
expect(component.find('DialogSidebar').length).toBeTruthy(); expect(component.find('DialogSidebar').length).toBeTruthy();
}); });
@ -240,20 +245,134 @@ describe('<BeaconViewDialog />', () => {
const component = getComponent(); const component = getComponent();
// open the sidebar // open the sidebar
act(() => { openSidebar(component);
findByTestId(component, 'beacon-view-dialog-open-sidebar').at(0).simulate('click');
component.setProps({});
});
expect(component.find('DialogSidebar').length).toBeTruthy(); expect(component.find('DialogSidebar').length).toBeTruthy();
// now close it // now close it
act(() => { act(() => {
findByTestId(component, 'dialog-sidebar-close').at(0).simulate('click'); findByAttr('data-testid')(component, 'dialog-sidebar-close').at(0).simulate('click');
component.setProps({}); component.setProps({});
}); });
expect(component.find('DialogSidebar').length).toBeFalsy(); expect(component.find('DialogSidebar').length).toBeFalsy();
}); });
}); });
describe('focused beacons', () => {
const beacon2Event = makeBeaconInfoEvent(bobId,
roomId,
{ isLive: true },
'$bob-room1-2',
);
const location2 = makeBeaconEvent(
bobId, { beaconInfoId: beacon2Event.getId(), geoUri: 'geo:33,22', timestamp: now + 1 },
);
const fitBoundsOptions = { maxZoom: 15, padding: 100 };
it('opens map with both beacons in view on first load without initialFocusedBeacon', () => {
const [beacon1, beacon2] = makeRoomWithBeacons(
roomId, mockClient, [defaultEvent, beacon2Event], [location1, location2],
);
getComponent({ beacons: [beacon1, beacon2] });
// start centered on mid point between both beacons
expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 42, lon: 31.5 });
// only called once
expect(mockMap.setCenter).toHaveBeenCalledTimes(1);
// bounds fit both beacons, only called once
expect(mockMap.fitBounds).toHaveBeenCalledWith(new maplibregl.LngLatBounds(
[22, 33], [41, 51],
), fitBoundsOptions);
expect(mockMap.fitBounds).toHaveBeenCalledTimes(1);
});
it('opens map with both beacons in view on first load with an initially focused beacon', () => {
const [beacon1, beacon2] = makeRoomWithBeacons(
roomId, mockClient, [defaultEvent, beacon2Event], [location1, location2],
);
getComponent({ beacons: [beacon1, beacon2], initialFocusedBeacon: beacon1 });
// start centered on initialFocusedBeacon
expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 51, lon: 41 });
// only called once
expect(mockMap.setCenter).toHaveBeenCalledTimes(1);
// bounds fit both beacons, only called once
expect(mockMap.fitBounds).toHaveBeenCalledWith(new maplibregl.LngLatBounds(
[22, 33], [41, 51],
), fitBoundsOptions);
expect(mockMap.fitBounds).toHaveBeenCalledTimes(1);
});
it('focuses on beacon location on sidebar list item click', () => {
const [beacon1, beacon2] = makeRoomWithBeacons(
roomId, mockClient, [defaultEvent, beacon2Event], [location1, location2],
);
const component = getComponent({ beacons: [beacon1, beacon2] });
// reset call counts on map mocks after initial render
jest.clearAllMocks();
openSidebar(component);
act(() => {
// click on the first beacon in the list
component.find(BeaconListItem).at(0).simulate('click');
});
// centered on clicked beacon
expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 51, lon: 41 });
// only called once
expect(mockMap.setCenter).toHaveBeenCalledTimes(1);
// bounds fitted just to clicked beacon
expect(mockMap.fitBounds).toHaveBeenCalledWith(new maplibregl.LngLatBounds(
[41, 51], [41, 51],
), fitBoundsOptions);
expect(mockMap.fitBounds).toHaveBeenCalledTimes(1);
});
it('refocuses on same beacon when clicking list item again', () => {
// test the map responds to refocusing the same beacon
const [beacon1, beacon2] = makeRoomWithBeacons(
roomId, mockClient, [defaultEvent, beacon2Event], [location1, location2],
);
const component = getComponent({ beacons: [beacon1, beacon2] });
// reset call counts on map mocks after initial render
jest.clearAllMocks();
openSidebar(component);
act(() => {
// click on the second beacon in the list
component.find(BeaconListItem).at(1).simulate('click');
});
const expectedBounds = new maplibregl.LngLatBounds(
[22, 33], [22, 33],
);
// date is mocked but this relies on timestamp, manually mock a tick
jest.spyOn(global.Date, 'now').mockReturnValue(now + 1);
act(() => {
// click on the second beacon in the list
component.find(BeaconListItem).at(1).simulate('click');
});
// centered on clicked beacon
expect(mockMap.setCenter).toHaveBeenCalledWith({ lat: 33, lon: 22 });
// bounds fitted just to clicked beacon
expect(mockMap.fitBounds).toHaveBeenCalledWith(expectedBounds, fitBoundsOptions);
// each called once per click
expect(mockMap.setCenter).toHaveBeenCalledTimes(2);
expect(mockMap.fitBounds).toHaveBeenCalledTimes(2);
});
});
}); });

View file

@ -15,31 +15,88 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import { mount } from 'enzyme'; import { fireEvent, render } from '@testing-library/react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import DialogSidebar from '../../../../src/components/views/beacon/DialogSidebar'; import DialogSidebar from '../../../../src/components/views/beacon/DialogSidebar';
import { findByTestId } from '../../../test-utils'; import MatrixClientContext from '../../../../src/contexts/MatrixClientContext';
import {
getMockClientWithEventEmitter,
makeBeaconEvent,
makeBeaconInfoEvent,
makeRoomWithBeacons,
mockClientMethodsUser,
} from '../../../test-utils';
describe('<DialogSidebar />', () => { describe('<DialogSidebar />', () => {
const defaultProps = { const defaultProps = {
beacons: [], beacons: [],
requestClose: jest.fn(), requestClose: jest.fn(),
onBeaconClick: jest.fn(),
}; };
const getComponent = (props = {}) =>
mount(<DialogSidebar {...defaultProps} {...props} />);
it('renders sidebar correctly', () => { const now = 1647270879403;
const component = getComponent();
expect(component).toMatchSnapshot(); const roomId = '!room:server.org';
const aliceId = '@alice:server.org';
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(aliceId),
getRoom: jest.fn(),
});
const beaconEvent = makeBeaconInfoEvent(aliceId,
roomId,
{ isLive: true, timestamp: now },
'$alice-room1-1',
);
const location1 = makeBeaconEvent(
aliceId, { beaconInfoId: beaconEvent.getId(), geoUri: 'geo:51,41', timestamp: now },
);
const getComponent = (props = {}) => (
<MatrixClientContext.Provider value={client}>
<DialogSidebar {...defaultProps} {...props} />);
</MatrixClientContext.Provider>);
beforeEach(() => {
// mock now so time based text in snapshots is stable
jest.spyOn(Date, 'now').mockReturnValue(now);
});
afterAll(() => {
jest.spyOn(Date, 'now').mockRestore();
});
it('renders sidebar correctly without beacons', () => {
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
});
it('renders sidebar correctly with beacons', () => {
const [beacon] = makeRoomWithBeacons(roomId, client, [beaconEvent], [location1]);
const { container } = render(getComponent({ beacons: [beacon] }));
expect(container).toMatchSnapshot();
});
it('calls on beacon click', () => {
const onBeaconClick = jest.fn();
const [beacon] = makeRoomWithBeacons(roomId, client, [beaconEvent], [location1]);
const { container } = render(getComponent({ beacons: [beacon], onBeaconClick }));
act(() => {
const [listItem] = container.getElementsByClassName('mx_BeaconListItem');
fireEvent.click(listItem);
});
expect(onBeaconClick).toHaveBeenCalled();
}); });
it('closes on close button click', () => { it('closes on close button click', () => {
const requestClose = jest.fn(); const requestClose = jest.fn();
const component = getComponent({ requestClose }); const { getByTestId } = render(getComponent({ requestClose }));
act(() => { act(() => {
findByTestId(component, 'dialog-sidebar-close').at(0).simulate('click'); fireEvent.click(getByTestId('dialog-sidebar-close'));
}); });
expect(requestClose).toHaveBeenCalled(); expect(requestClose).toHaveBeenCalled();
}); });

View file

@ -1,3 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<BeaconListItem /> when a beacon is live and has locations renders beacon info 1`] = `"<li class=\\"mx_BeaconListItem\\"><div class=\\"mx_StyledLiveBeaconIcon mx_BeaconListItem_avatarIcon\\"></div><div class=\\"mx_BeaconListItem_info\\"><div class=\\"mx_BeaconStatus mx_BeaconStatus_Active mx_BeaconListItem_status\\"><div class=\\"mx_BeaconStatus_description\\"><span class=\\"mx_BeaconStatus_label\\">Alice's car</span><span class=\\"mx_BeaconStatus_expiryTime\\">Live until 16:04</span></div><div tabindex=\\"0\\"><a data-test-id=\\"open-location-in-osm\\" href=\\"https://www.openstreetmap.org/?mlat=51&amp;mlon=41#map=16/51/41\\" target=\\"_blank\\" rel=\\"noreferrer noopener\\"><div class=\\"mx_ShareLatestLocation_icon\\"></div></a></div><div class=\\"mx_CopyableText mx_ShareLatestLocation_copy\\"><div aria-label=\\"Copy\\" role=\\"button\\" tabindex=\\"0\\" class=\\"mx_AccessibleButton mx_CopyableText_copyButton\\"></div></div></div><span class=\\"mx_BeaconListItem_lastUpdated\\">Updated a few seconds ago</span></div></li>"`; exports[`<BeaconListItem /> when a beacon is live and has locations renders beacon info 1`] = `"<li class=\\"mx_BeaconListItem\\"><div class=\\"mx_StyledLiveBeaconIcon mx_BeaconListItem_avatarIcon\\"></div><div class=\\"mx_BeaconListItem_info\\"><div class=\\"mx_BeaconStatus mx_BeaconStatus_Active mx_BeaconListItem_status\\"><div class=\\"mx_BeaconStatus_description\\"><span class=\\"mx_BeaconStatus_label\\">Alice's car</span><span class=\\"mx_BeaconStatus_expiryTime\\">Live until 16:04</span></div><div class=\\"mx_BeaconListItem_interactions\\"><div tabindex=\\"0\\"><a data-test-id=\\"open-location-in-osm\\" href=\\"https://www.openstreetmap.org/?mlat=51&amp;mlon=41#map=16/51/41\\" target=\\"_blank\\" rel=\\"noreferrer noopener\\"><div class=\\"mx_ShareLatestLocation_icon\\"></div></a></div><div class=\\"mx_CopyableText mx_ShareLatestLocation_copy\\"><div aria-label=\\"Copy\\" role=\\"button\\" tabindex=\\"0\\" class=\\"mx_AccessibleButton mx_CopyableText_copyButton\\"></div></div></div></div><span class=\\"mx_BeaconListItem_lastUpdated\\">Updated a few seconds ago</span></div></li>"`;

View file

@ -1,53 +1,144 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<DialogSidebar /> renders sidebar correctly 1`] = ` exports[`<DialogSidebar /> renders sidebar correctly with beacons 1`] = `
<DialogSidebar <div>
beacons={Array []}
requestClose={[MockFunction]}
>
<div <div
className="mx_DialogSidebar" class="mx_DialogSidebar"
> >
<div <div
className="mx_DialogSidebar_header" class="mx_DialogSidebar_header"
> >
<Heading <h4
size="h4" class="mx_Heading_h4"
> >
<h4 View List
className="mx_Heading_h4" </h4>
> <div
View List class="mx_AccessibleButton mx_DialogSidebar_closeButton"
</h4> data-testid="dialog-sidebar-close"
</Heading>
<AccessibleButton
className="mx_DialogSidebar_closeButton"
data-test-id="dialog-sidebar-close"
element="div"
onClick={[MockFunction]}
role="button" role="button"
tabIndex={0} tabindex="0"
title="Close sidebar" title="Close sidebar"
> >
<div <div
className="mx_AccessibleButton mx_DialogSidebar_closeButton" class="mx_DialogSidebar_closeButtonIcon"
data-test-id="dialog-sidebar-close" />
onClick={[MockFunction]} </div>
onKeyDown={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex={0}
title="Close sidebar"
>
<div
className="mx_DialogSidebar_closeButtonIcon"
/>
</div>
</AccessibleButton>
</div> </div>
<ol <ol
className="mx_DialogSidebar_list" class="mx_DialogSidebar_list"
>
<li
class="mx_BeaconListItem"
>
<span
class="mx_BaseAvatar mx_BeaconListItem_avatar"
role="presentation"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 20.8px; width: 32px; line-height: 32px;"
/>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
src=""
style="width: 32px; height: 32px;"
/>
</span>
<div
class="mx_BeaconListItem_info"
>
<div
class="mx_BeaconStatus mx_BeaconStatus_Active mx_BeaconListItem_status"
>
<div
class="mx_BeaconStatus_description"
>
<span
class="mx_BeaconStatus_label"
>
@alice:server.org
</span>
<span
class="mx_BeaconStatus_expiryTime"
>
Live until 16:14
</span>
</div>
<div
class="mx_BeaconListItem_interactions"
>
<div
tabindex="0"
>
<a
data-test-id="open-location-in-osm"
href="https://www.openstreetmap.org/?mlat=51&mlon=41#map=16/51/41"
rel="noreferrer noopener"
target="_blank"
>
<div
class="mx_ShareLatestLocation_icon"
/>
</a>
</div>
<div
class="mx_CopyableText mx_ShareLatestLocation_copy"
>
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
</div>
</div>
<span
class="mx_BeaconListItem_lastUpdated"
>
Updated a few seconds ago
</span>
</div>
</li>
</ol>
</div>
);
</div>
`;
exports[`<DialogSidebar /> renders sidebar correctly without beacons 1`] = `
<div>
<div
class="mx_DialogSidebar"
>
<div
class="mx_DialogSidebar_header"
>
<h4
class="mx_Heading_h4"
>
View List
</h4>
<div
class="mx_AccessibleButton mx_DialogSidebar_closeButton"
data-testid="dialog-sidebar-close"
role="button"
tabindex="0"
title="Close sidebar"
>
<div
class="mx_DialogSidebar_closeButtonIcon"
/>
</div>
</div>
<ol
class="mx_DialogSidebar_list"
/> />
</div> </div>
</DialogSidebar> );
</div>
`; `;

View file

@ -205,7 +205,11 @@ export const makeRoomWithBeacons = (
const room = makeRoomWithStateEvents(beaconInfoEvents, { roomId, mockClient }); const room = makeRoomWithStateEvents(beaconInfoEvents, { roomId, mockClient });
const beacons = beaconInfoEvents.map(event => room.currentState.beacons.get(getBeaconInfoIdentifier(event))); const beacons = beaconInfoEvents.map(event => room.currentState.beacons.get(getBeaconInfoIdentifier(event)));
if (locationEvents) { if (locationEvents) {
beacons.forEach(beacon => beacon.addLocations(locationEvents)); beacons.forEach(beacon => {
// this filtering happens in roomState, which is bypassed here
const validLocationEvents = locationEvents?.filter(event => event.getSender() === beacon.beaconInfoOwner);
beacon.addLocations(validLocationEvents);
});
} }
return beacons; return beacons;
}; };

View file

@ -35,7 +35,8 @@ export function untilDispatch(waitForAction: DispatcherAction): Promise<ActionPa
}); });
} }
const findByAttr = (attr: string) => (component: ReactWrapper, value: string) => component.find(`[${attr}="${value}"]`); export const findByAttr = (attr: string) => (component: ReactWrapper, value: string) =>
component.find(`[${attr}="${value}"]`);
export const findByTestId = findByAttr('data-test-id'); export const findByTestId = findByAttr('data-test-id');
export const findById = findByAttr('id'); export const findById = findByAttr('id');
export const findByAriaLabel = findByAttr('aria-label'); export const findByAriaLabel = findByAttr('aria-label');