From 590b845f3f4bfa076c758bfa137cc75816b70023 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 18 Nov 2022 09:22:43 +0000 Subject: [PATCH] Remove all usages of UNSAFE_* React methods (#9583) --- src/components/structures/InteractiveAuth.tsx | 3 +- src/components/structures/MatrixChat.tsx | 13 +- src/components/structures/TimelinePanel.tsx | 16 +-- .../structures/auth/ForgotPassword.tsx | 21 ++- src/components/structures/auth/Login.tsx | 22 ++- .../structures/auth/Registration.tsx | 12 +- src/components/views/avatars/MemberAvatar.tsx | 20 +-- src/components/views/elements/AppTile.tsx | 22 +-- src/components/views/elements/Dropdown.tsx | 23 ++-- .../views/elements/EditableText.tsx | 8 +- src/components/views/elements/Pill.tsx | 33 +++-- .../views/elements/PowerSelector.tsx | 30 ++-- src/components/views/rooms/EventTile.tsx | 24 +--- src/components/views/rooms/MemberList.tsx | 5 +- .../views/rooms/SendMessageComposer.tsx | 19 ++- .../settings/discovery/EmailAddresses.tsx | 10 +- .../views/settings/discovery/PhoneNumbers.tsx | 10 +- .../tabs/room/NotificationSettingsTab.tsx | 18 +-- .../tabs/user/GeneralUserSettingsTab.tsx | 37 +++-- .../structures/auth/ForgotPassword-test.tsx | 102 ++++++++++++++ .../components/structures/auth/Login-test.tsx | 129 ++++++++++-------- .../structures/auth/Registration-test.tsx | 110 +++++++++------ .../__snapshots__/BeaconMarker-test.tsx.snap | 46 ------- .../views/elements/PowerSelector-test.tsx | 16 +++ .../__snapshots__/TextualBody-test.tsx.snap | 2 +- .../views/rooms/MessageComposer-test.tsx | 4 +- .../views/rooms/SendMessageComposer-test.tsx | 114 ++++++---------- .../discovery/EmailAddresses-test.tsx | 40 ++++++ .../settings/discovery/PhoneNumbers-test.tsx | 40 ++++++ .../room/NotificationSettingsTab-test.tsx | 23 +++- test/setup/setupManualMocks.ts | 2 +- test/test-utils/composer.ts | 16 ++- test/test-utils/platform.ts | 2 +- 33 files changed, 582 insertions(+), 410 deletions(-) create mode 100644 test/components/structures/auth/ForgotPassword-test.tsx create mode 100644 test/components/views/settings/discovery/EmailAddresses-test.tsx create mode 100644 test/components/views/settings/discovery/PhoneNumbers-test.tsx diff --git a/src/components/structures/InteractiveAuth.tsx b/src/components/structures/InteractiveAuth.tsx index c1f723c63a6..b33fb73791d 100644 --- a/src/components/structures/InteractiveAuth.tsx +++ b/src/components/structures/InteractiveAuth.tsx @@ -133,8 +133,7 @@ export default class InteractiveAuthComponent extends React.Component { const extra = { emailSid: this.authLogic.getEmailSid(), diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index e80b99e32dc..c64bc2b843c 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -403,12 +403,17 @@ export default class MatrixChat extends React.PureComponent { this.setState({ pendingInitialSync: false }); } - // TODO: [REACT-WARNING] Replace with appropriate lifecycle stage - // eslint-disable-next-line - UNSAFE_componentWillUpdate(props, state) { - if (this.shouldTrackPageChange(this.state, state)) { + public setState( + state: (( + prevState: Readonly, + props: Readonly, + ) => (Pick | IState | null)) | (Pick | IState | null), + callback?: () => void, + ): void { + if (this.shouldTrackPageChange(this.state, { ...this.state, ...state })) { this.startPageChangeTimer(); } + super.setState(state, callback); } public componentDidMount(): void { diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index 25d40dfaabd..bf8f1187397 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -299,22 +299,17 @@ class TimelinePanel extends React.Component { cli.on(ClientEvent.Sync, this.onSync); } - // TODO: [REACT-WARNING] Move into constructor - // eslint-disable-next-line - UNSAFE_componentWillMount() { + public componentDidMount() { if (this.props.manageReadReceipts) { this.updateReadReceiptOnUserActivity(); } if (this.props.manageReadMarkers) { this.updateReadMarkerOnUserActivity(); } - this.initTimeline(this.props); } - // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - // eslint-disable-next-line - UNSAFE_componentWillReceiveProps(newProps) { + public componentDidUpdate(newProps) { if (newProps.timelineSet !== this.props.timelineSet) { // throw new Error("changing timelineSet on a TimelinePanel is not supported"); @@ -334,10 +329,9 @@ class TimelinePanel extends React.Component { const differentHighlightedEventId = newProps.highlightedEventId != this.props.highlightedEventId; const differentAvoidJump = newProps.eventScrollIntoView && !this.props.eventScrollIntoView; if (differentEventId || differentHighlightedEventId || differentAvoidJump) { - logger.log("TimelinePanel switching to " + - "eventId " + newProps.eventId + " (was " + this.props.eventId + "), " + - "scrollIntoView: " + newProps.eventScrollIntoView + " (was " + this.props.eventScrollIntoView + ")"); - return this.initTimeline(newProps); + logger.log(`TimelinePanel switching to eventId ${newProps.eventId} (was ${this.props.eventId}), ` + + `scrollIntoView: ${newProps.eventScrollIntoView} (was ${this.props.eventScrollIntoView})`); + this.initTimeline(newProps); } } diff --git a/src/components/structures/auth/ForgotPassword.tsx b/src/components/structures/auth/ForgotPassword.tsx index 83a8e3e5651..4605f8fa0e1 100644 --- a/src/components/structures/auth/ForgotPassword.tsx +++ b/src/components/structures/auth/ForgotPassword.tsx @@ -113,17 +113,16 @@ export default class ForgotPassword extends React.Component { this.checkServerCapabilities(this.props.serverConfig); } - // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - // eslint-disable-next-line - public UNSAFE_componentWillReceiveProps(newProps: IProps): void { - if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl && - newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; - - // Do a liveliness check on the new URLs - this.checkServerLiveliness(newProps.serverConfig); - - // Do capabilities check on new URLs - this.checkServerCapabilities(newProps.serverConfig); + public componentDidUpdate(prevProps: Readonly) { + if (prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl || + prevProps.serverConfig.isUrl !== this.props.serverConfig.isUrl + ) { + // Do a liveliness check on the new URLs + this.checkServerLiveliness(this.props.serverConfig); + + // Do capabilities check on new URLs + this.checkServerCapabilities(this.props.serverConfig); + } } private async checkServerLiveliness(serverConfig): Promise { diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index 7c1564c9d94..7b3d3725e69 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -144,9 +144,7 @@ export default class LoginComponent extends React.PureComponent }; } - // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - // eslint-disable-next-line - UNSAFE_componentWillMount() { + public componentDidMount() { this.initLoginLogic(this.props.serverConfig); } @@ -154,14 +152,13 @@ export default class LoginComponent extends React.PureComponent this.unmounted = true; } - // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - // eslint-disable-next-line - UNSAFE_componentWillReceiveProps(newProps) { - if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl && - newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; - - // Ensure that we end up actually logging in to the right place - this.initLoginLogic(newProps.serverConfig); + public componentDidUpdate(prevProps) { + if (prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl || + prevProps.serverConfig.isUrl !== this.props.serverConfig.isUrl + ) { + // Ensure that we end up actually logging in to the right place + this.initLoginLogic(this.props.serverConfig); + } } isBusy = () => this.state.busy || this.props.busy; @@ -369,7 +366,8 @@ export default class LoginComponent extends React.PureComponent let isDefaultServer = false; if (this.props.serverConfig.isDefault && hsUrl === this.props.serverConfig.hsUrl - && isUrl === this.props.serverConfig.isUrl) { + && isUrl === this.props.serverConfig.isUrl + ) { isDefaultServer = true; } diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx index ab88c446ef2..df7b8c12295 100644 --- a/src/components/structures/auth/Registration.tsx +++ b/src/components/structures/auth/Registration.tsx @@ -165,13 +165,13 @@ export default class Registration extends React.Component { return ""; } }; - // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - // eslint-disable-next-line - UNSAFE_componentWillReceiveProps(newProps) { - if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl && - newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; - this.replaceClient(newProps.serverConfig); + public componentDidUpdate(prevProps) { + if (prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl || + prevProps.serverConfig.isUrl !== this.props.serverConfig.isUrl + ) { + this.replaceClient(this.props.serverConfig); + } } private async replaceClient(serverConfig: ValidatedServerConfig) { diff --git a/src/components/views/avatars/MemberAvatar.tsx b/src/components/views/avatars/MemberAvatar.tsx index 959fc84c47a..40be5bffeb0 100644 --- a/src/components/views/avatars/MemberAvatar.tsx +++ b/src/components/views/avatars/MemberAvatar.tsx @@ -49,17 +49,21 @@ export default function MemberAvatar({ height, resizeMethod = 'crop', viewUserOnClick, + forceHistorical, + fallbackUserId, + hideTitle, + member: propsMember, ...props }: IProps) { const card = useContext(CardContext); const member = useRoomMemberProfile({ - userId: props.member?.userId, - member: props.member, - forceHistorical: props.forceHistorical, + userId: propsMember?.userId, + member: propsMember, + forceHistorical: forceHistorical, }); - const name = member?.name ?? props.fallbackUserId; + const name = member?.name ?? fallbackUserId; let title: string | undefined = props.title; let imageUrl: string | undefined; if (member?.name) { @@ -74,7 +78,7 @@ export default function MemberAvatar({ if (!title) { title = UserIdentifierCustomisations.getDisplayUserIdentifier( member?.userId ?? "", { roomId: member?.roomId ?? "" }, - ) ?? props.fallbackUserId; + ) ?? fallbackUserId; } } @@ -84,13 +88,13 @@ export default function MemberAvatar({ height={height} resizeMethod={resizeMethod} name={name ?? ""} - title={props.hideTitle ? undefined : title} - idName={member?.userId ?? props.fallbackUserId} + title={hideTitle ? undefined : title} + idName={member?.userId ?? fallbackUserId} url={imageUrl} onClick={viewUserOnClick ? () => { dis.dispatch({ action: Action.ViewUser, - member: props.member, + member: propsMember, push: card.isCard, }); } : props.onClick} diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index 7480774c05a..2b214110c57 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -99,7 +99,6 @@ interface IState { isUserProfileReady: boolean; error: Error; menuDisplayed: boolean; - widgetPageTitle: string; requiresClient: boolean; } @@ -229,7 +228,6 @@ export default class AppTile extends React.Component { isUserProfileReady: OwnProfileStore.instance.isProfileInfoFetched, error: null, menuDisplayed: false, - widgetPageTitle: this.props.widgetPageTitle, requiresClient: this.determineInitialRequiresClientState(), }; } @@ -351,21 +349,13 @@ export default class AppTile extends React.Component { } }; - // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - // eslint-disable-next-line @typescript-eslint/naming-convention - public UNSAFE_componentWillReceiveProps(nextProps: IProps): void { // eslint-disable-line camelcase - if (nextProps.app.url !== this.props.app.url) { - this.getNewState(nextProps); + public componentDidUpdate(prevProps: IProps): void { + if (prevProps.app.url !== this.props.app.url) { + this.getNewState(this.props); if (this.state.hasPermissionToLoad) { - this.resetWidget(nextProps); + this.resetWidget(this.props); } } - - if (nextProps.widgetPageTitle !== this.props.widgetPageTitle) { - this.setState({ - widgetPageTitle: nextProps.widgetPageTitle, - }); - } } /** @@ -474,8 +464,8 @@ export default class AppTile extends React.Component { const name = this.formatAppTileName(); const titleSpacer =  - ; let title = ''; - if (this.state.widgetPageTitle && this.state.widgetPageTitle !== this.formatAppTileName()) { - title = this.state.widgetPageTitle; + if (this.props.widgetPageTitle && this.props.widgetPageTitle !== this.formatAppTileName()) { + title = this.props.widgetPageTitle; } return ( diff --git a/src/components/views/elements/Dropdown.tsx b/src/components/views/elements/Dropdown.tsx index 39124bad0da..7a00d050e01 100644 --- a/src/components/views/elements/Dropdown.tsx +++ b/src/components/views/elements/Dropdown.tsx @@ -22,6 +22,7 @@ import AccessibleButton, { ButtonEvent } from './AccessibleButton'; import { _t } from '../../../languageHandler'; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; +import { objectHasDiff } from "../../../utils/objects"; interface IMenuOptionProps { children: ReactElement; @@ -136,20 +137,18 @@ export default class Dropdown extends React.Component { document.addEventListener('click', this.onDocumentClick, false); } - componentWillUnmount() { - document.removeEventListener('click', this.onDocumentClick, false); + public componentDidUpdate(prevProps: Readonly) { + if (objectHasDiff(this.props, prevProps) && this.props.children?.length) { + this.reindexChildren(this.props.children); + const firstChild = this.props.children[0]; + this.setState({ + highlightedOption: String(firstChild?.key) ?? null, + }); + } } - // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line - if (!nextProps.children || nextProps.children.length === 0) { - return; - } - this.reindexChildren(nextProps.children); - const firstChild = nextProps.children[0]; - this.setState({ - highlightedOption: firstChild ? firstChild.key : null, - }); + componentWillUnmount() { + document.removeEventListener('click', this.onDocumentClick, false); } private reindexChildren(children: ReactElement[]): void { diff --git a/src/components/views/elements/EditableText.tsx b/src/components/views/elements/EditableText.tsx index 30f28624f49..f035989c935 100644 --- a/src/components/views/elements/EditableText.tsx +++ b/src/components/views/elements/EditableText.tsx @@ -70,11 +70,9 @@ export default class EditableText extends React.Component { }; } - // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - // eslint-disable-next-line @typescript-eslint/naming-convention, camelcase - public UNSAFE_componentWillReceiveProps(nextProps: IProps): void { - if (nextProps.initialValue !== this.props.initialValue) { - this.value = nextProps.initialValue; + public componentDidUpdate(prevProps: Readonly): void { + if (prevProps.initialValue !== this.props.initialValue) { + this.value = this.props.initialValue; if (this.editableDiv.current) { this.showPlaceholder(!this.value); } diff --git a/src/components/views/elements/Pill.tsx b/src/components/views/elements/Pill.tsx index ab5313c210c..e4fe24ccebd 100644 --- a/src/components/views/elements/Pill.tsx +++ b/src/components/views/elements/Pill.tsx @@ -29,6 +29,7 @@ import { Action } from "../../../dispatcher/actions"; import Tooltip, { Alignment } from './Tooltip'; import RoomAvatar from '../avatars/RoomAvatar'; import MemberAvatar from '../avatars/MemberAvatar'; +import { objectHasDiff } from "../../../utils/objects"; export enum PillType { UserMention = 'TYPE_USER_MENTION', @@ -86,19 +87,17 @@ export default class Pill extends React.Component { }; } - // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - // eslint-disable-next-line camelcase, @typescript-eslint/naming-convention - public async UNSAFE_componentWillReceiveProps(nextProps: IProps): Promise { - let resourceId; - let prefix; + private load(): void { + let resourceId: string; + let prefix: string; - if (nextProps.url) { - if (nextProps.inMessage) { - const parts = parsePermalink(nextProps.url); + if (this.props.url) { + if (this.props.inMessage) { + const parts = parsePermalink(this.props.url); resourceId = parts.primaryEntityId; // The room/user ID prefix = parts.sigil; // The first character of prefix } else { - resourceId = getPrimaryPermalinkEntity(nextProps.url); + resourceId = getPrimaryPermalinkEntity(this.props.url); prefix = resourceId ? resourceId[0] : undefined; } } @@ -109,15 +108,15 @@ export default class Pill extends React.Component { '!': PillType.RoomMention, }[prefix]; - let member; - let room; + let member: RoomMember; + let room: Room; switch (pillType) { case PillType.AtRoomMention: { - room = nextProps.room; + room = this.props.room; } break; case PillType.UserMention: { - const localMember = nextProps.room ? nextProps.room.getMember(resourceId) : undefined; + const localMember = this.props.room?.getMember(resourceId); member = localMember; if (!localMember) { member = new RoomMember(null, resourceId); @@ -146,9 +145,13 @@ export default class Pill extends React.Component { public componentDidMount(): void { this.unmounted = false; this.matrixClient = MatrixClientPeg.get(); + this.load(); + } - // eslint-disable-next-line new-cap - this.UNSAFE_componentWillReceiveProps(this.props); // HACK: We shouldn't be calling lifecycle functions ourselves. + public componentDidUpdate(prevProps: Readonly) { + if (objectHasDiff(this.props, prevProps)) { + this.load(); + } } public componentWillUnmount(): void { diff --git a/src/components/views/elements/PowerSelector.tsx b/src/components/views/elements/PowerSelector.tsx index 8b251b91a51..396e071bdb0 100644 --- a/src/components/views/elements/PowerSelector.tsx +++ b/src/components/views/elements/PowerSelector.tsx @@ -21,6 +21,7 @@ import { _t } from '../../../languageHandler'; import Field from "./Field"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; +import { objectHasDiff } from "../../../utils/objects"; const CUSTOM_VALUE = "SELECT_VALUE_CUSTOM"; @@ -72,36 +73,35 @@ export default class PowerSelector extends React.Component { }; } - // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - // eslint-disable-next-line camelcase, @typescript-eslint/naming-convention - public UNSAFE_componentWillMount(): void { - this.initStateFromProps(this.props); + public componentDidMount() { + this.initStateFromProps(); } - // eslint-disable-next-line camelcase, @typescript-eslint/naming-convention - public UNSAFE_componentWillReceiveProps(newProps: IProps): void { - this.initStateFromProps(newProps); + public componentDidUpdate(prevProps: Readonly) { + if (objectHasDiff(this.props, prevProps)) { + this.initStateFromProps(); + } } - private initStateFromProps(newProps: IProps): void { + private initStateFromProps(): void { // This needs to be done now because levelRoleMap has translated strings - const levelRoleMap = Roles.levelRoleMap(newProps.usersDefault); + const levelRoleMap = Roles.levelRoleMap(this.props.usersDefault); const options = Object.keys(levelRoleMap).filter(level => { return ( level === undefined || - parseInt(level) <= newProps.maxValue || - parseInt(level) == newProps.value + parseInt(level) <= this.props.maxValue || + parseInt(level) == this.props.value ); }).map(level => parseInt(level)); - const isCustom = levelRoleMap[newProps.value] === undefined; + const isCustom = levelRoleMap[this.props.value] === undefined; this.setState({ levelRoleMap, options, custom: isCustom, - customValue: newProps.value, - selectValue: isCustom ? CUSTOM_VALUE : newProps.value, + customValue: this.props.value, + selectValue: isCustom ? CUSTOM_VALUE : this.props.value, }); } @@ -127,7 +127,7 @@ export default class PowerSelector extends React.Component { if (Number.isFinite(this.state.customValue)) { this.props.onChange(this.state.customValue, this.props.powerLevelKey); } else { - this.initStateFromProps(this.props); // reset, invalid input + this.initStateFromProps(); // reset, invalid input } }; diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 4a3b1ebf8d6..df2a5f448ef 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -369,12 +369,6 @@ export class UnwrappedEventTile extends React.Component return true; } - // TODO: [REACT-WARNING] Move into constructor - // eslint-disable-next-line - UNSAFE_componentWillMount() { - this.verifyEvent(this.props.mxEvent); - } - componentDidMount() { this.suppressReadReceiptAnimation = false; const client = MatrixClientPeg.get(); @@ -405,6 +399,8 @@ export class UnwrappedEventTile extends React.Component const room = client.getRoom(this.props.mxEvent.getRoomId()); room?.on(ThreadEvent.New, this.onNewThread); + + this.verifyEvent(this.props.mxEvent); } private get supportsThreadNotifications(): boolean { @@ -451,16 +447,6 @@ export class UnwrappedEventTile extends React.Component this.setState({ thread }); }; - // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - // eslint-disable-next-line - UNSAFE_componentWillReceiveProps(nextProps: EventTileProps) { - // re-check the sender verification as outgoing events progress through - // the send process. - if (nextProps.eventSendStatus !== this.props.eventSendStatus) { - this.verifyEvent(nextProps.mxEvent); - } - } - shouldComponentUpdate(nextProps: EventTileProps, nextState: IState): boolean { if (objectHasDiff(this.state, nextState)) { return true; @@ -490,12 +476,16 @@ export class UnwrappedEventTile extends React.Component } } - componentDidUpdate() { + componentDidUpdate(prevProps: Readonly) { // If we're not listening for receipts and expect to be, register a listener. if (!this.isListeningForReceipts && (this.shouldShowSentReceipt || this.shouldShowSendingReceipt)) { MatrixClientPeg.get().on(RoomEvent.Receipt, this.onRoomReceipt); this.isListeningForReceipts = true; } + // re-check the sender verification as outgoing events progress through the send process. + if (prevProps.eventSendStatus !== this.props.eventSendStatus) { + this.verifyEvent(this.props.mxEvent); + } } private onNewThread = (thread: Thread) => { diff --git a/src/components/views/rooms/MemberList.tsx b/src/components/views/rooms/MemberList.tsx index 28db302fe67..26f069f2700 100644 --- a/src/components/views/rooms/MemberList.tsx +++ b/src/components/views/rooms/MemberList.tsx @@ -96,8 +96,7 @@ export default class MemberList extends React.Component { this.showPresence = enablePresenceByHsUrl?.[hsUrl] ?? true; } - // eslint-disable-next-line - UNSAFE_componentWillMount() { + public componentDidMount() { const cli = MatrixClientPeg.get(); this.mounted = true; if (cli.hasLazyLoadMembersEnabled()) { @@ -121,7 +120,7 @@ export default class MemberList extends React.Component { cli.on(UserEvent.CurrentlyActive, this.onUserPresenceChange); } - componentWillUnmount() { + public componentWillUnmount() { this.mounted = false; const cli = MatrixClientPeg.get(); if (cli) { diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index 8c423663f5d..a9e1eaa6cef 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -159,7 +159,9 @@ export class SendMessageComposer extends React.Component) { - super(props); + super(props, context); + this.context = context; // otherwise React will only set it prior to render due to type def above + if (this.props.mxClient.isCryptoEnabled() && this.props.mxClient.isRoomEncrypted(this.props.room.roomId)) { this.prepareToEncrypt = throttle(() => { this.props.mxClient.prepareToEncrypt(this.props.room); @@ -167,6 +169,12 @@ export class SendMessageComposer extends React.Component) { + if (this.props.email !== prevProps.email) { + const { bound } = this.props.email; + this.setState({ bound }); + } } private async changeBinding({ bind, label, errorTitle }): Promise { diff --git a/src/components/views/settings/discovery/PhoneNumbers.tsx b/src/components/views/settings/discovery/PhoneNumbers.tsx index 9d9c4f5569b..3724da54df1 100644 --- a/src/components/views/settings/discovery/PhoneNumbers.tsx +++ b/src/components/views/settings/discovery/PhoneNumbers.tsx @@ -63,11 +63,11 @@ export class PhoneNumber extends React.Component) { + if (this.props.msisdn !== prevProps.msisdn) { + const { bound } = this.props.msisdn; + this.setState({ bound }); + } } private async changeBinding({ bind, label, errorTitle }): Promise { diff --git a/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx b/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx index 76e8bee812e..b770b6cb4fa 100644 --- a/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx @@ -55,22 +55,18 @@ export default class NotificationsSettingsTab extends React.Component => { e.stopPropagation(); e.preventDefault(); diff --git a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx index ffcd12f3cc5..dbbc2e059e8 100644 --- a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx @@ -107,25 +107,8 @@ export default class GeneralUserSettingsTab extends React.Component { - const cli = MatrixClientPeg.get(); - - const serverSupportsSeparateAddAndBind = await cli.doesServerSupportSeparateAddAndBind(); - - const capabilities = await cli.getCapabilities(); // this is cached - const changePasswordCap = capabilities['m.change_password']; - - // You can change your password so long as the capability isn't explicitly disabled. The implicit - // behaviour is you can change your password when the capability is missing or has not-false as - // the enabled flag value. - const canChangePassword = !changePasswordCap || changePasswordCap['enabled'] !== false; - - this.setState({ serverSupportsSeparateAddAndBind, canChangePassword }); + this.getCapabilities(); this.getThreepidState(); } @@ -163,6 +146,22 @@ export default class GeneralUserSettingsTab extends React.Component { + const cli = MatrixClientPeg.get(); + + const serverSupportsSeparateAddAndBind = await cli.doesServerSupportSeparateAddAndBind(); + + const capabilities = await cli.getCapabilities(); // this is cached + const changePasswordCap = capabilities['m.change_password']; + + // You can change your password so long as the capability isn't explicitly disabled. The implicit + // behaviour is you can change your password when the capability is missing or has not-false as + // the enabled flag value. + const canChangePassword = !changePasswordCap || changePasswordCap['enabled'] !== false; + + this.setState({ serverSupportsSeparateAddAndBind, canChangePassword }); + } + private async getThreepidState(): Promise { const cli = MatrixClientPeg.get(); @@ -171,7 +170,7 @@ export default class GeneralUserSettingsTab extends React.Component (jest.fn().mockReturnValue({ + resetPassword: jest.fn().mockReturnValue(new Promise(() => {})), +}))); +jest.useFakeTimers(); + +describe('', () => { + const mockClient = mocked({ + doesServerSupportLogoutDevices: jest.fn().mockResolvedValue(true), + } as unknown as MatrixClient); + + beforeEach(function() { + SdkConfig.put({ + ...DEFAULTS, + disable_custom_urls: true, + }); + mocked(createClient).mockImplementation(opts => { + mockClient.idBaseUrl = opts.idBaseUrl; + mockClient.baseUrl = opts.baseUrl; + return mockClient; + }); + fetchMock.get("https://matrix.org/_matrix/client/versions", { + unstable_features: {}, + versions: [], + }); + mockPlatformPeg({ + startSingleSignOn: jest.fn(), + }); + }); + + afterEach(function() { + fetchMock.restore(); + SdkConfig.unset(); // we touch the config, so clean up + unmockPlatformPeg(); + }); + + const defaultProps = { + defaultDeviceDisplayName: 'test-device-display-name', + onServerConfigChange: jest.fn(), + onLoginClick: jest.fn(), + onComplete: jest.fn(), + }; + + function getRawComponent(hsUrl = "https://matrix.org", isUrl = "https://vector.im") { + return ; + } + + it("should handle serverConfig updates correctly", async () => { + const { container, rerender } = render(getRawComponent()); + await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading...")); + + fetchMock.get("https://server2/_matrix/client/versions", { + unstable_features: {}, + versions: [], + }); + fetchMock.get("https://vector.im/_matrix/identity/api/v1", {}); + rerender(getRawComponent("https://server2")); + + const email = "email@addy.com"; + const pass = "thisIsAT0tallySecurePassword"; + + fireEvent.change(container.querySelector('[label=Email]'), { target: { value: email } }); + fireEvent.change(container.querySelector('[label="New Password"]'), { target: { value: pass } }); + fireEvent.change(container.querySelector('[label=Confirm]'), { target: { value: pass } }); + fireEvent.change(container.querySelector('[type=checkbox]')); // this allows us to bypass the modal + fireEvent.submit(container.querySelector("form")); + + await waitFor(() => { + return expect(PasswordReset).toHaveBeenCalledWith("https://server2", expect.anything()); + }, { timeout: 5000 }); + }); +}); diff --git a/test/components/structures/auth/Login-test.tsx b/test/components/structures/auth/Login-test.tsx index 44c44ffd26b..3db8c6f4e0b 100644 --- a/test/components/structures/auth/Login-test.tsx +++ b/test/components/structures/auth/Login-test.tsx @@ -14,25 +14,24 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { Component } from 'react'; -import ReactDOM from 'react-dom'; -import ReactTestUtils from 'react-dom/test-utils'; -import { mocked } from 'jest-mock'; +import React from 'react'; +import { fireEvent, render, screen, waitForElementToBeRemoved } from "@testing-library/react"; +import { mocked, MockedObject } from 'jest-mock'; import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix"; +import fetchMock from "fetch-mock-jest"; import SdkConfig from '../../../../src/SdkConfig'; -import { mkServerConfig } from "../../../test-utils"; +import { mkServerConfig, mockPlatformPeg, unmockPlatformPeg } from "../../../test-utils"; import Login from "../../../../src/components/structures/auth/Login"; -import PasswordLogin from "../../../../src/components/views/auth/PasswordLogin"; +import BasePlatform from "../../../../src/BasePlatform"; jest.mock("matrix-js-sdk/src/matrix"); -const flushPromises = async () => await new Promise(process.nextTick); - jest.useRealTimers(); describe('Login', function() { - let parentDiv; + let platform: MockedObject; + const mockClient = mocked({ login: jest.fn().mockResolvedValue({}), loginFlows: jest.fn(), @@ -45,25 +44,37 @@ describe('Login', function() { }); mockClient.login.mockClear().mockResolvedValue({}); mockClient.loginFlows.mockClear().mockResolvedValue({ flows: [{ type: "m.login.password" }] }); - mocked(createClient).mockReturnValue(mockClient); - - parentDiv = document.createElement('div'); - document.body.appendChild(parentDiv); + mocked(createClient).mockImplementation(opts => { + mockClient.idBaseUrl = opts.idBaseUrl; + mockClient.baseUrl = opts.baseUrl; + return mockClient; + }); + fetchMock.get("https://matrix.org/_matrix/client/versions", { + unstable_features: {}, + versions: [], + }); + platform = mockPlatformPeg({ + startSingleSignOn: jest.fn(), + }); }); afterEach(function() { - ReactDOM.unmountComponentAtNode(parentDiv); - parentDiv.remove(); + fetchMock.restore(); SdkConfig.unset(); // we touch the config, so clean up + unmockPlatformPeg(); }); - function render() { - return ReactDOM.render( { }} onRegisterClick={() => { }} onServerConfigChange={() => { }} - />, parentDiv) as unknown as Component; + />; + } + + function getComponent(hsUrl?: string, isUrl?: string) { + return render(getRawComponent(hsUrl, isUrl)); } it('should show form with change server link', async () => { @@ -71,54 +82,41 @@ describe('Login', function() { brand: "test-brand", disable_custom_urls: false, }); - const root = render(); - - await flushPromises(); + const { container } = getComponent(); + await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading...")); - const form = ReactTestUtils.findRenderedComponentWithType( - root, - PasswordLogin, - ); - expect(form).toBeTruthy(); + expect(container.querySelector("form")).toBeTruthy(); - const changeServerLink = ReactTestUtils.findRenderedDOMComponentWithClass(root, 'mx_ServerPicker_change'); - expect(changeServerLink).toBeTruthy(); + expect(container.querySelector(".mx_ServerPicker_change")).toBeTruthy(); }); it('should show form without change server link when custom URLs disabled', async () => { - const root = render(); - await flushPromises(); + const { container } = getComponent(); + await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading...")); - const form = ReactTestUtils.findRenderedComponentWithType( - root, - PasswordLogin, - ); - expect(form).toBeTruthy(); - - const changeServerLinks = ReactTestUtils.scryRenderedDOMComponentsWithClass(root, 'mx_ServerPicker_change'); - expect(changeServerLinks).toHaveLength(0); + expect(container.querySelector("form")).toBeTruthy(); + expect(container.querySelectorAll(".mx_ServerPicker_change")).toHaveLength(0); }); it("should show SSO button if that flow is available", async () => { mockClient.loginFlows.mockResolvedValue({ flows: [{ type: "m.login.sso" }] }); - const root = render(); - await flushPromises(); + const { container } = getComponent(); + await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading...")); - const ssoButton = ReactTestUtils.findRenderedDOMComponentWithClass(root, "mx_SSOButton"); + const ssoButton = container.querySelector(".mx_SSOButton"); expect(ssoButton).toBeTruthy(); }); it("should show both SSO button and username+password if both are available", async () => { mockClient.loginFlows.mockResolvedValue({ flows: [{ type: "m.login.password" }, { type: "m.login.sso" }] }); - const root = render(); - await flushPromises(); + const { container } = getComponent(); + await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading...")); - const form = ReactTestUtils.findRenderedComponentWithType(root, PasswordLogin); - expect(form).toBeTruthy(); + expect(container.querySelector("form")).toBeTruthy(); - const ssoButton = ReactTestUtils.findRenderedDOMComponentWithClass(root, "mx_SSOButton"); + const ssoButton = container.querySelector(".mx_SSOButton"); expect(ssoButton).toBeTruthy(); }); @@ -139,11 +137,10 @@ describe('Login', function() { }], }); - const root = render(); - - await flushPromises(); + const { container } = getComponent(); + await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading...")); - const ssoButtons = ReactTestUtils.scryRenderedDOMComponentsWithClass(root, "mx_SSOButton"); + const ssoButtons = container.querySelectorAll(".mx_SSOButton"); expect(ssoButtons.length).toBe(3); }); @@ -154,11 +151,33 @@ describe('Login', function() { }], }); - const root = render(); - - await flushPromises(); + const { container } = getComponent(); + await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading...")); - const ssoButtons = ReactTestUtils.scryRenderedDOMComponentsWithClass(root, "mx_SSOButton"); + const ssoButtons = container.querySelectorAll(".mx_SSOButton"); expect(ssoButtons.length).toBe(1); }); + + it("should handle serverConfig updates correctly", async () => { + mockClient.loginFlows.mockResolvedValue({ + flows: [{ + "type": "m.login.sso", + }], + }); + + const { container, rerender } = render(getRawComponent()); + await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading...")); + + fireEvent.click(container.querySelector(".mx_SSOButton")); + expect(platform.startSingleSignOn.mock.calls[0][0].baseUrl).toBe("https://matrix.org"); + + fetchMock.get("https://server2/_matrix/client/versions", { + unstable_features: {}, + versions: [], + }); + rerender(getRawComponent("https://server2")); + + fireEvent.click(container.querySelector(".mx_SSOButton")); + expect(platform.startSingleSignOn.mock.calls[1][0].baseUrl).toBe("https://server2"); + }); }); diff --git a/test/components/structures/auth/Registration-test.tsx b/test/components/structures/auth/Registration-test.tsx index 45bb25b79e3..8d156b41471 100644 --- a/test/components/structures/auth/Registration-test.tsx +++ b/test/components/structures/auth/Registration-test.tsx @@ -16,93 +16,115 @@ limitations under the License. */ import React from 'react'; -import ReactDOM from 'react-dom'; -import ReactTestUtils from 'react-dom/test-utils'; -import { createClient } from 'matrix-js-sdk/src/matrix'; +import { fireEvent, render, screen, waitForElementToBeRemoved } from "@testing-library/react"; +import { createClient, MatrixClient } from 'matrix-js-sdk/src/matrix'; +import { MatrixError } from 'matrix-js-sdk/src/http-api/errors'; import { mocked } from 'jest-mock'; +import fetchMock from "fetch-mock-jest"; import SdkConfig, { DEFAULTS } from '../../../../src/SdkConfig'; -import { createTestClient, mkServerConfig } from "../../../test-utils"; +import { mkServerConfig, mockPlatformPeg, unmockPlatformPeg } from "../../../test-utils"; import Registration from "../../../../src/components/structures/auth/Registration"; -import RegistrationForm from "../../../../src/components/views/auth/RegistrationForm"; jest.mock('matrix-js-sdk/src/matrix'); jest.useFakeTimers(); describe('Registration', function() { - let parentDiv; + const registerRequest = jest.fn(); + const mockClient = mocked({ + registerRequest, + loginFlows: jest.fn(), + } as unknown as MatrixClient); beforeEach(function() { SdkConfig.put({ ...DEFAULTS, disable_custom_urls: true, }); - parentDiv = document.createElement('div'); - document.body.appendChild(parentDiv); - mocked(createClient).mockImplementation(() => createTestClient()); + mockClient.registerRequest.mockRejectedValueOnce(new MatrixError({ + flows: [{ stages: [] }], + }, 401)); + mockClient.loginFlows.mockClear().mockResolvedValue({ flows: [{ type: "m.login.password" }] }); + mocked(createClient).mockImplementation(opts => { + mockClient.idBaseUrl = opts.idBaseUrl; + mockClient.baseUrl = opts.baseUrl; + return mockClient; + }); + fetchMock.get("https://matrix.org/_matrix/client/versions", { + unstable_features: {}, + versions: [], + }); + mockPlatformPeg({ + startSingleSignOn: jest.fn(), + }); }); afterEach(function() { - ReactDOM.unmountComponentAtNode(parentDiv); - parentDiv.remove(); + fetchMock.restore(); SdkConfig.unset(); // we touch the config, so clean up + unmockPlatformPeg(); }); const defaultProps = { defaultDeviceDisplayName: 'test-device-display-name', - serverConfig: mkServerConfig("https://matrix.org", "https://vector.im"), makeRegistrationUrl: jest.fn(), onLoggedIn: jest.fn(), onLoginClick: jest.fn(), onServerConfigChange: jest.fn(), }; - function render() { - return ReactDOM.render(, parentDiv) as React.Component; + serverConfig={mkServerConfig(hsUrl, isUrl)} + />; + } + + function getComponent(hsUrl?: string, isUrl?: string) { + return render(getRawComponent(hsUrl, isUrl)); } it('should show server picker', async function() { - const root = render(); - const selector = ReactTestUtils.findRenderedDOMComponentWithClass(root, "mx_ServerPicker"); - expect(selector).toBeTruthy(); + const { container } = getComponent(); + expect(container.querySelector(".mx_ServerPicker")).toBeTruthy(); }); it('should show form when custom URLs disabled', async function() { - const root = render(); + const { container } = getComponent(); + await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading...")); + expect(container.querySelector("form")).toBeTruthy(); + }); + + it("should show SSO options if those are available", async () => { + mockClient.loginFlows.mockClear().mockResolvedValue({ flows: [{ type: "m.login.sso" }] }); + const { container } = getComponent(); + await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading...")); - // Set non-empty flows & matrixClient to get past the loading spinner - root.setState({ + const ssoButton = container.querySelector(".mx_SSOButton"); + expect(ssoButton).toBeTruthy(); + }); + + it("should handle serverConfig updates correctly", async () => { + mockClient.loginFlows.mockResolvedValue({ flows: [{ - stages: [], + "type": "m.login.sso", }], - matrixClient: {}, - busy: false, }); - const form = ReactTestUtils.findRenderedComponentWithType( - root, - RegistrationForm, - ); - expect(form).toBeTruthy(); - }); + const { container, rerender } = render(getRawComponent()); + await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading...")); - it("should show SSO options if those are available", async () => { - const root = render(); + fireEvent.click(container.querySelector(".mx_SSOButton")); + expect(registerRequest.mock.instances[0].baseUrl).toBe("https://matrix.org"); - // Set non-empty flows & matrixClient to get past the loading spinner - root.setState({ - flows: [{ - stages: [], - }], - ssoFlow: { - type: "m.login.sso", - }, - matrixClient: {}, - busy: false, + fetchMock.get("https://server2/_matrix/client/versions", { + unstable_features: {}, + versions: [], }); + rerender(getRawComponent("https://server2")); + await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading...")); - const ssoButton = ReactTestUtils.findRenderedDOMComponentWithClass(root, "mx_SSOButton"); - expect(ssoButton).toBeTruthy(); + fireEvent.click(container.querySelector(".mx_SSOButton")); + expect(registerRequest.mock.instances[1].baseUrl).toBe("https://server2"); }); }); diff --git a/test/components/views/beacon/__snapshots__/BeaconMarker-test.tsx.snap b/test/components/views/beacon/__snapshots__/BeaconMarker-test.tsx.snap index ad62c932cb0..3871be4a83a 100644 --- a/test/components/views/beacon/__snapshots__/BeaconMarker-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/BeaconMarker-test.tsx.snap @@ -189,30 +189,7 @@ exports[` renders marker when beacon has location 1`] = ` > renders marker when beacon has location 1`] = ` > ', () => { await screen.findByDisplayValue(40); expect(fn).toHaveBeenCalledWith(40, "key"); }); + + it("should reset when props get changed", async () => { + const fn = jest.fn(); + const { rerender } = render(); + + const select = screen.getByLabelText("Power level"); + fireEvent.change(select, { target: { value: "SELECT_VALUE_CUSTOM" } }); + + rerender(); + await screen.findByDisplayValue(51); + + rerender(); + const option = await screen.findByText("Moderator"); + expect(option.selected).toBeTruthy(); + expect(fn).not.toHaveBeenCalled(); + }); }); diff --git a/test/components/views/messages/__snapshots__/TextualBody-test.tsx.snap b/test/components/views/messages/__snapshots__/TextualBody-test.tsx.snap index 6f8070eef61..d2af91e1cfd 100644 --- a/test/components/views/messages/__snapshots__/TextualBody-test.tsx.snap +++ b/test/components/views/messages/__snapshots__/TextualBody-test.tsx.snap @@ -14,4 +14,4 @@ exports[` renders formatted m.text correctly pills do not appear " `; -exports[` renders formatted m.text correctly pills get injected correctly into the DOM 1`] = `"Hey Member"`; +exports[` renders formatted m.text correctly pills get injected correctly into the DOM 1`] = `"Hey Member"`; diff --git a/test/components/views/rooms/MessageComposer-test.tsx b/test/components/views/rooms/MessageComposer-test.tsx index 4ef5966a73f..6cf71c653ea 100644 --- a/test/components/views/rooms/MessageComposer-test.tsx +++ b/test/components/views/rooms/MessageComposer-test.tsx @@ -38,7 +38,7 @@ import dis from "../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../src/dispatcher/actions"; import { SendMessageComposer } from "../../../../src/components/views/rooms/SendMessageComposer"; import { E2EStatus } from "../../../../src/utils/ShieldUtils"; -import { addTextToComposer } from "../../../test-utils/composer"; +import { addTextToComposerEnzyme } from "../../../test-utils/composer"; import UIStore, { UI_EVENTS } from "../../../../src/stores/UIStore"; import { SendWysiwygComposer } from "../../../../src/components/views/rooms/wysiwyg_composer"; @@ -176,7 +176,7 @@ describe("MessageComposer", () => { beforeEach(() => { wrapper = wrapAndRender({ room }); - addTextToComposer(wrapper, "Hello"); + addTextToComposerEnzyme(wrapper, "Hello"); wrapper.update(); }); diff --git a/test/components/views/rooms/SendMessageComposer-test.tsx b/test/components/views/rooms/SendMessageComposer-test.tsx index b9bdae45d4e..4aaf870119d 100644 --- a/test/components/views/rooms/SendMessageComposer-test.tsx +++ b/test/components/views/rooms/SendMessageComposer-test.tsx @@ -15,17 +15,13 @@ limitations under the License. */ import React from "react"; -import { act } from "react-dom/test-utils"; -import { sleep } from "matrix-js-sdk/src/utils"; -import { MatrixClient, MsgType, RelationType } from "matrix-js-sdk/src/matrix"; -// eslint-disable-next-line deprecate/import -import { mount } from 'enzyme'; +import { fireEvent, render, waitFor } from "@testing-library/react"; +import { MatrixClient, MsgType } from "matrix-js-sdk/src/matrix"; import { mocked } from "jest-mock"; import SendMessageComposer, { createMessageContent, isQuickReaction, - SendMessageComposer as SendMessageComposerClass, } from "../../../../src/components/views/rooms/SendMessageComposer"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext"; @@ -46,15 +42,6 @@ jest.mock("../../../../src/utils/local-room", () => ({ doMaybeLocalRoomAction: jest.fn(), })); -const WrapWithProviders: React.FC<{ - roomContext: IRoomState; - client: MatrixClient; -}> = ({ children, roomContext, client }) => - - { children } - -; - describe('', () => { const defaultRoomContext: IRoomState = { roomLoading: true, @@ -194,44 +181,48 @@ describe('', () => { toggleStickerPickerOpen: jest.fn(), permalinkCreator: new RoomPermalinkCreator(mockRoom), }; + const getRawComponent = (props = {}, roomContext = defaultRoomContext, client = mockClient) => ( + + + + + + ); const getComponent = (props = {}, roomContext = defaultRoomContext, client = mockClient) => { - return mount(, { - wrappingComponent: WrapWithProviders, - wrappingComponentProps: { roomContext, client }, - }); + return render(getRawComponent(props, roomContext, client)); }; it("renders text and placeholder correctly", () => { - const wrapper = getComponent({ placeholder: "placeholder string" }); + const { container } = getComponent({ placeholder: "placeholder string" }); - expect(wrapper.find('[aria-label="placeholder string"]')).toHaveLength(1); + expect(container.querySelectorAll('[aria-label="placeholder string"]')).toHaveLength(1); - addTextToComposer(wrapper, "Test Text"); + addTextToComposer(container, "Test Text"); - expect(wrapper.text()).toBe("Test Text"); + expect(container.textContent).toBe("Test Text"); }); it("correctly persists state to and from localStorage", () => { - const wrapper = getComponent({ replyToEvent: mockEvent }); + const props = { replyToEvent: mockEvent }; + const { container, unmount, rerender } = getComponent(props); - addTextToComposer(wrapper, "Test Text"); + addTextToComposer(container, "Test Text"); - // @ts-ignore - const key = wrapper.find(SendMessageComposerClass).instance().editorStateKey; + const key = "mx_cider_state_myfakeroom"; - expect(wrapper.text()).toBe("Test Text"); + expect(container.textContent).toBe("Test Text"); expect(localStorage.getItem(key)).toBeNull(); // ensure the right state was persisted to localStorage - wrapper.unmount(); + unmount(); expect(JSON.parse(localStorage.getItem(key))).toStrictEqual({ parts: [{ "type": "plain", "text": "Test Text" }], replyEventId: mockEvent.getId(), }); // ensure the correct model is re-loaded - wrapper.mount(); - expect(wrapper.text()).toBe("Test Text"); + rerender(getRawComponent(props)); + expect(container.textContent).toBe("Test Text"); expect(spyDispatcher).toHaveBeenCalledWith({ action: "reply_to_event", event: mockEvent, @@ -239,21 +230,20 @@ describe('', () => { }); // now try with localStorage wiped out - wrapper.unmount(); + unmount(); localStorage.removeItem(key); - wrapper.mount(); - expect(wrapper.text()).toBe(""); + rerender(getRawComponent(props)); + expect(container.textContent).toBe(""); }); it("persists state correctly without replyToEvent onbeforeunload", () => { - const wrapper = getComponent(); + const { container } = getComponent(); - addTextToComposer(wrapper, "Hello World"); + addTextToComposer(container, "Hello World"); - // @ts-ignore - const key = wrapper.find(SendMessageComposerClass).instance().editorStateKey; + const key = "mx_cider_state_myfakeroom"; - expect(wrapper.text()).toBe("Hello World"); + expect(container.textContent).toBe("Hello World"); expect(localStorage.getItem(key)).toBeNull(); // ensure the right state was persisted to localStorage @@ -266,22 +256,20 @@ describe('', () => { it("persists to session history upon sending", async () => { mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) }); - const wrapper = getComponent({ replyToEvent: mockEvent }); + const { container } = getComponent({ replyToEvent: mockEvent }); - addTextToComposer(wrapper, "This is a message"); - act(() => { - wrapper.find(".mx_SendMessageComposer").simulate("keydown", { key: "Enter" }); - wrapper.update(); - }); - await sleep(10); // await the async _sendMessage - wrapper.update(); - expect(spyDispatcher).toHaveBeenCalledWith({ - action: "reply_to_event", - event: null, - context: TimelineRenderingType.Room, + addTextToComposer(container, "This is a message"); + fireEvent.keyDown(container.querySelector(".mx_SendMessageComposer"), { key: "Enter" }); + + await waitFor(() => { + expect(spyDispatcher).toHaveBeenCalledWith({ + action: "reply_to_event", + event: null, + context: TimelineRenderingType.Room, + }); }); - expect(wrapper.text()).toBe(""); + expect(container.textContent).toBe(""); const str = sessionStorage.getItem(`mx_cider_history_${mockRoom.roomId}[0]`); expect(JSON.parse(str)).toStrictEqual({ parts: [{ "type": "plain", "text": "This is a message" }], @@ -289,19 +277,6 @@ describe('', () => { }); }); - it('correctly sets the editorStateKey for threads', () => { - const relation = { - rel_type: RelationType.Thread, - event_id: "myFakeThreadId", - }; - const includeReplyLegacyFallback = false; - const wrapper = getComponent({ relation, includeReplyLegacyFallback }); - const instance = wrapper.find(SendMessageComposerClass).instance(); - // @ts-ignore - const key = instance.editorStateKey; - expect(key).toEqual('mx_cider_state_myfakeroom_myFakeThreadId'); - }); - it("correctly sends a message", () => { mocked(doMaybeLocalRoomAction).mockImplementation(( roomId: string, @@ -312,13 +287,10 @@ describe('', () => { }); mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) }); - const wrapper = getComponent(); + const { container } = getComponent(); - addTextToComposer(wrapper, "test message"); - act(() => { - wrapper.find(".mx_SendMessageComposer").simulate("keydown", { key: "Enter" }); - wrapper.update(); - }); + addTextToComposer(container, "test message"); + fireEvent.keyDown(container.querySelector(".mx_SendMessageComposer"), { key: "Enter" }); expect(mockClient.sendMessage).toHaveBeenCalledWith( "myfakeroom", diff --git a/test/components/views/settings/discovery/EmailAddresses-test.tsx b/test/components/views/settings/discovery/EmailAddresses-test.tsx new file mode 100644 index 00000000000..8f885582f42 --- /dev/null +++ b/test/components/views/settings/discovery/EmailAddresses-test.tsx @@ -0,0 +1,40 @@ +/* +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'; +import { render, screen } from "@testing-library/react"; +import { IThreepid, ThreepidMedium } from 'matrix-js-sdk/src/@types/threepids'; + +import { EmailAddress } from '../../../../../src/components/views/settings/discovery/EmailAddresses'; + +describe("", () => { + it("should track props.email.bound changes", async () => { + const email: IThreepid = { + medium: ThreepidMedium.Email, + address: "foo@bar.com", + validated_at: 12345, + added_at: 12342, + bound: false, + }; + + const { rerender } = render(); + await screen.findByText("Share"); + + email.bound = true; + rerender(); + await screen.findByText("Revoke"); + }); +}); diff --git a/test/components/views/settings/discovery/PhoneNumbers-test.tsx b/test/components/views/settings/discovery/PhoneNumbers-test.tsx new file mode 100644 index 00000000000..899f5a2254d --- /dev/null +++ b/test/components/views/settings/discovery/PhoneNumbers-test.tsx @@ -0,0 +1,40 @@ +/* +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'; +import { render, screen } from "@testing-library/react"; +import { IThreepid, ThreepidMedium } from 'matrix-js-sdk/src/@types/threepids'; + +import { PhoneNumber } from "../../../../../src/components/views/settings/discovery/PhoneNumbers"; + +describe("", () => { + it("should track props.msisdn.bound changes", async () => { + const msisdn: IThreepid = { + medium: ThreepidMedium.Phone, + address: "+441111111111", + validated_at: 12345, + added_at: 12342, + bound: false, + }; + + const { rerender } = render(); + await screen.findByText("Share"); + + msisdn.bound = true; + rerender(); + await screen.findByText("Revoke"); + }); +}); diff --git a/test/components/views/settings/tabs/room/NotificationSettingsTab-test.tsx b/test/components/views/settings/tabs/room/NotificationSettingsTab-test.tsx index a48a7fc135e..d8414e9d92d 100644 --- a/test/components/views/settings/tabs/room/NotificationSettingsTab-test.tsx +++ b/test/components/views/settings/tabs/room/NotificationSettingsTab-test.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React from "react"; -import { render, RenderResult } from "@testing-library/react"; +import { render, RenderResult, screen } from "@testing-library/react"; import { MatrixClient } from "matrix-js-sdk/src/client"; import userEvent from "@testing-library/user-event"; @@ -24,6 +24,8 @@ import { mkStubRoom, stubClient } from "../../../../../test-utils"; import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg"; import { EchoChamber } from "../../../../../../src/stores/local-echo/EchoChamber"; import { RoomEchoChamber } from "../../../../../../src/stores/local-echo/RoomEchoChamber"; +import SettingsStore from "../../../../../../src/settings/SettingsStore"; +import { SettingLevel } from "../../../../../../src/settings/SettingLevel"; describe("NotificatinSettingsTab", () => { const roomId = "!room:example.com"; @@ -55,4 +57,23 @@ describe("NotificatinSettingsTab", () => { expect(roomProps.notificationVolume).not.toBe("mentions_only"); }); + + it("should show the currently chosen custom notification sound", async () => { + SettingsStore.setValue("notificationSound", roomId, SettingLevel.ACCOUNT, { + url: "mxc://server/custom-sound-123", + name: "custom-sound-123", + }); + renderTab(); + + await screen.findByText("custom-sound-123"); + }); + + it("should show the currently chosen custom notification sound url if no name", async () => { + SettingsStore.setValue("notificationSound", roomId, SettingLevel.ACCOUNT, { + url: "mxc://server/custom-sound-123", + }); + renderTab(); + + await screen.findByText("http://this.is.a.url/server/custom-sound-123"); + }); }); diff --git a/test/setup/setupManualMocks.ts b/test/setup/setupManualMocks.ts index d627430ba70..ada613feb99 100644 --- a/test/setup/setupManualMocks.ts +++ b/test/setup/setupManualMocks.ts @@ -52,7 +52,7 @@ class DOMRect { window.DOMRect = DOMRect; // Work around missing ClipboardEvent type -class MyClipboardEvent {} +class MyClipboardEvent extends Event {} window.ClipboardEvent = MyClipboardEvent as any; // matchMedia is not included in jsdom diff --git a/test/test-utils/composer.ts b/test/test-utils/composer.ts index abfb694d966..5c43c09e07a 100644 --- a/test/test-utils/composer.ts +++ b/test/test-utils/composer.ts @@ -17,8 +17,22 @@ limitations under the License. // eslint-disable-next-line deprecate/import import { ReactWrapper } from "enzyme"; import { act } from "react-dom/test-utils"; +import { fireEvent } from "@testing-library/react"; -export const addTextToComposer = (wrapper: ReactWrapper, text: string) => act(() => { +export const addTextToComposer = (container: HTMLElement, text: string) => act(() => { + // couldn't get input event on contenteditable to work + // paste works without illegal private method access + const pasteEvent = { + clipboardData: { + types: [], + files: [], + getData: type => type === "text/plain" ? text : undefined, + }, + }; + fireEvent.paste(container.querySelector('[role="textbox"]'), pasteEvent); +}); + +export const addTextToComposerEnzyme = (wrapper: ReactWrapper, text: string) => act(() => { // couldn't get input event on contenteditable to work // paste works without illegal private method access const pasteEvent = { diff --git a/test/test-utils/platform.ts b/test/test-utils/platform.ts index a9dfb09b1bf..1d61c1ef129 100644 --- a/test/test-utils/platform.ts +++ b/test/test-utils/platform.ts @@ -30,7 +30,7 @@ class MockPlatform extends BasePlatform { /** * Mock Platform Peg * Creates a mock BasePlatform class - * spys on PlatformPeg.get and returns mock platform + * spies on PlatformPeg.get and returns mock platform * @returns MockPlatform instance */ export const mockPlatformPeg = (