Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit d89a462

Browse files
author
Kerry
authored
use stable reference for active tab in tabbedView (#9145)
1 parent 2c4ee7e commit d89a462

File tree

3 files changed

+227
-18
lines changed

3 files changed

+227
-18
lines changed

src/components/structures/TabbedView.tsx

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -60,31 +60,25 @@ interface IProps {
6060
}
6161

6262
interface IState {
63-
activeTabIndex: number;
63+
activeTabId: string;
6464
}
6565

6666
export default class TabbedView extends React.Component<IProps, IState> {
6767
constructor(props: IProps) {
6868
super(props);
6969

70-
let activeTabIndex = 0;
71-
if (props.initialTabId) {
72-
const tabIndex = props.tabs.findIndex(t => t.id === props.initialTabId);
73-
if (tabIndex >= 0) activeTabIndex = tabIndex;
74-
}
75-
70+
const initialTabIdIsValid = props.tabs.find(tab => tab.id === props.initialTabId);
7671
this.state = {
77-
activeTabIndex,
72+
activeTabId: initialTabIdIsValid ? props.initialTabId : props.tabs[0]?.id,
7873
};
7974
}
8075

8176
static defaultProps = {
8277
tabLocation: TabLocation.LEFT,
8378
};
8479

85-
private getActiveTabIndex() {
86-
if (!this.state || !this.state.activeTabIndex) return 0;
87-
return this.state.activeTabIndex;
80+
private getTabById(id: string): Tab | undefined {
81+
return this.props.tabs.find(tab => tab.id === id);
8882
}
8983

9084
/**
@@ -93,10 +87,10 @@ export default class TabbedView extends React.Component<IProps, IState> {
9387
* @private
9488
*/
9589
private setActiveTab(tab: Tab) {
96-
const idx = this.props.tabs.indexOf(tab);
97-
if (idx !== -1) {
90+
// make sure this tab is still in available tabs
91+
if (!!this.getTabById(tab.id)) {
9892
if (this.props.onChange) this.props.onChange(tab.id);
99-
this.setState({ activeTabIndex: idx });
93+
this.setState({ activeTabId: tab.id });
10094
} else {
10195
logger.error("Could not find tab " + tab.label + " in tabs");
10296
}
@@ -105,8 +99,7 @@ export default class TabbedView extends React.Component<IProps, IState> {
10599
private renderTabLabel(tab: Tab) {
106100
let classes = "mx_TabbedView_tabLabel ";
107101

108-
const idx = this.props.tabs.indexOf(tab);
109-
if (idx === this.getActiveTabIndex()) classes += "mx_TabbedView_tabLabel_active";
102+
if (this.state.activeTabId === tab.id) classes += "mx_TabbedView_tabLabel_active";
110103

111104
let tabIcon = null;
112105
if (tab.icon) {
@@ -143,8 +136,8 @@ export default class TabbedView extends React.Component<IProps, IState> {
143136

144137
public render(): React.ReactNode {
145138
const labels = this.props.tabs.map(tab => this.renderTabLabel(tab));
146-
const tab = this.props.tabs[this.getActiveTabIndex()];
147-
const panel = this.renderTabPanel(tab);
139+
const tab = this.getTabById(this.state.activeTabId);
140+
const panel = tab ? this.renderTabPanel(tab) : null;
148141

149142
const tabbedViewClasses = classNames({
150143
'mx_TabbedView': true,
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import React from 'react';
18+
import { fireEvent, render } from "@testing-library/react";
19+
import { act } from 'react-dom/test-utils';
20+
21+
import TabbedView, { Tab, TabLocation } from "../../../src/components/structures/TabbedView";
22+
23+
describe('<TabbedView />', () => {
24+
const generalTab = new Tab(
25+
'GENERAL',
26+
'General',
27+
'general',
28+
<div>general</div>,
29+
);
30+
const labsTab = new Tab(
31+
'LABS',
32+
'Labs',
33+
'labs',
34+
<div>labs</div>,
35+
);
36+
const securityTab = new Tab(
37+
'SECURITY',
38+
'Security',
39+
'security',
40+
<div>security</div>,
41+
);
42+
const defaultProps = {
43+
tabLocation: TabLocation.LEFT,
44+
tabs: [generalTab, labsTab, securityTab],
45+
};
46+
const getComponent = (props = {}): React.ReactElement => <TabbedView {...defaultProps} {...props} />;
47+
48+
const getTabTestId = (tab: Tab): string => `settings-tab-${tab.id}`;
49+
const getActiveTab = (container: HTMLElement): Element | undefined =>
50+
container.getElementsByClassName('mx_TabbedView_tabLabel_active')[0];
51+
const getActiveTabBody = (container: HTMLElement): Element | undefined =>
52+
container.getElementsByClassName('mx_TabbedView_tabPanel')[0];
53+
54+
it('renders tabs', () => {
55+
const { container } = render(getComponent());
56+
expect(container).toMatchSnapshot();
57+
});
58+
59+
it('renders first tab as active tab when no initialTabId', () => {
60+
const { container } = render(getComponent());
61+
expect(getActiveTab(container).textContent).toEqual(generalTab.label);
62+
expect(getActiveTabBody(container).textContent).toEqual('general');
63+
});
64+
65+
it('renders first tab as active tab when initialTabId is not valid', () => {
66+
const { container } = render(getComponent({ initialTabId: 'bad-tab-id' }));
67+
expect(getActiveTab(container).textContent).toEqual(generalTab.label);
68+
expect(getActiveTabBody(container).textContent).toEqual('general');
69+
});
70+
71+
it('renders initialTabId tab as active when valid', () => {
72+
const { container } = render(getComponent({ initialTabId: securityTab.id }));
73+
expect(getActiveTab(container).textContent).toEqual(securityTab.label);
74+
expect(getActiveTabBody(container).textContent).toEqual('security');
75+
});
76+
77+
it('renders without error when there are no tabs', () => {
78+
const { container } = render(getComponent({ tabs: [] }));
79+
expect(container).toMatchSnapshot();
80+
});
81+
82+
it('sets active tab on tab click', () => {
83+
const { container, getByTestId } = render(getComponent());
84+
85+
act(() => {
86+
fireEvent.click(getByTestId(getTabTestId(securityTab)));
87+
});
88+
89+
expect(getActiveTab(container).textContent).toEqual(securityTab.label);
90+
expect(getActiveTabBody(container).textContent).toEqual('security');
91+
});
92+
93+
it('calls onchange on on tab click', () => {
94+
const onChange = jest.fn();
95+
const { getByTestId } = render(getComponent({ onChange }));
96+
97+
act(() => {
98+
fireEvent.click(getByTestId(getTabTestId(securityTab)));
99+
});
100+
101+
expect(onChange).toHaveBeenCalledWith(securityTab.id);
102+
});
103+
104+
it('keeps same tab active when order of tabs changes', () => {
105+
// start with middle tab active
106+
const { container, rerender } = render(getComponent({ initialTabId: labsTab.id }));
107+
108+
expect(getActiveTab(container).textContent).toEqual(labsTab.label);
109+
110+
rerender(getComponent({ tabs: [labsTab, generalTab, securityTab] }));
111+
112+
// labs tab still active
113+
expect(getActiveTab(container).textContent).toEqual(labsTab.label);
114+
});
115+
116+
it('does not reactivate inititalTabId on rerender', () => {
117+
const { container, getByTestId, rerender } = render(getComponent());
118+
119+
expect(getActiveTab(container).textContent).toEqual(generalTab.label);
120+
121+
// make security tab active
122+
act(() => {
123+
fireEvent.click(getByTestId(getTabTestId(securityTab)));
124+
});
125+
expect(getActiveTab(container).textContent).toEqual(securityTab.label);
126+
127+
// rerender with new tab location
128+
rerender(getComponent({ tabLocation: TabLocation.TOP }));
129+
130+
// still security tab
131+
expect(getActiveTab(container).textContent).toEqual(securityTab.label);
132+
});
133+
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`<TabbedView /> renders tabs 1`] = `
4+
<div>
5+
<div
6+
class="mx_TabbedView mx_TabbedView_tabsOnLeft"
7+
>
8+
<div
9+
class="mx_TabbedView_tabLabels"
10+
>
11+
<div
12+
class="mx_AccessibleButton mx_TabbedView_tabLabel mx_TabbedView_tabLabel_active"
13+
data-testid="settings-tab-GENERAL"
14+
role="button"
15+
tabindex="0"
16+
>
17+
<span
18+
class="mx_TabbedView_maskedIcon general"
19+
/>
20+
<span
21+
class="mx_TabbedView_tabLabel_text"
22+
>
23+
General
24+
</span>
25+
</div>
26+
<div
27+
class="mx_AccessibleButton mx_TabbedView_tabLabel "
28+
data-testid="settings-tab-LABS"
29+
role="button"
30+
tabindex="0"
31+
>
32+
<span
33+
class="mx_TabbedView_maskedIcon labs"
34+
/>
35+
<span
36+
class="mx_TabbedView_tabLabel_text"
37+
>
38+
Labs
39+
</span>
40+
</div>
41+
<div
42+
class="mx_AccessibleButton mx_TabbedView_tabLabel "
43+
data-testid="settings-tab-SECURITY"
44+
role="button"
45+
tabindex="0"
46+
>
47+
<span
48+
class="mx_TabbedView_maskedIcon security"
49+
/>
50+
<span
51+
class="mx_TabbedView_tabLabel_text"
52+
>
53+
Security
54+
</span>
55+
</div>
56+
</div>
57+
<div
58+
class="mx_TabbedView_tabPanel"
59+
>
60+
<div
61+
class="mx_AutoHideScrollbar mx_TabbedView_tabPanelContent"
62+
tabindex="-1"
63+
>
64+
<div>
65+
general
66+
</div>
67+
</div>
68+
</div>
69+
</div>
70+
</div>
71+
`;
72+
73+
exports[`<TabbedView /> renders without error when there are no tabs 1`] = `
74+
<div>
75+
<div
76+
class="mx_TabbedView mx_TabbedView_tabsOnLeft"
77+
>
78+
<div
79+
class="mx_TabbedView_tabLabels"
80+
/>
81+
</div>
82+
</div>
83+
`;

0 commit comments

Comments
 (0)