close
Skip to content

Commit 04468fc

Browse files
jstoffanmergify[bot]
authored andcommitted
feat(sidebar): Add support for toggling the sidebar externally (#1293)
1 parent 62f78bb commit 04468fc

10 files changed

Lines changed: 162 additions & 322 deletions

File tree

‎src/elements/common/nav-button/NavButton.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ const NavButton = React.forwardRef<Props, React.Ref<any>>((props: Props, ref: Re
5555

5656
if (!event.defaultPrevented && isLeftClick(event)) {
5757
const method = replace ? history.replace : history.push;
58-
method(path);
58+
method(to);
5959
}
6060
}}
6161
ref={ref}

‎src/elements/content-sidebar/Sidebar.js‎

Lines changed: 47 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import * as React from 'react';
88
import classNames from 'classnames';
99
import flow from 'lodash/flow';
10+
import getProp from 'lodash/get';
1011
import uniqueid from 'lodash/uniqueId';
1112
import { withRouter } from 'react-router-dom';
1213
import type { Location, RouterHistory } from 'react-router-dom';
@@ -48,76 +49,51 @@ type Props = {
4849

4950
type State = {
5051
isDirty: boolean,
51-
isOpen: boolean, // Local isOpen state consists of stored forced state (if any) and responsive adjustments
5252
};
5353

5454
export const SIDEBAR_FORCE_KEY: 'bcs.force' = 'bcs.force';
5555
export const SIDEBAR_FORCE_VALUE_CLOSED: 'closed' = 'closed';
5656
export const SIDEBAR_FORCE_VALUE_OPEN: 'open' = 'open';
5757

5858
class Sidebar extends React.Component<Props, State> {
59+
static defaultProps = {
60+
isLarge: true,
61+
isLoading: false,
62+
};
63+
5964
id: string = uniqueid('bcs_');
6065

6166
props: Props;
6267

63-
state: State;
68+
state: State = {
69+
isDirty: false,
70+
};
6471

6572
store: LocalStore = new LocalStore();
6673

67-
static defaultProps = {
68-
isLarge: true,
69-
isLoading: false,
70-
};
71-
7274
constructor(props: Props) {
7375
super(props);
7476

75-
const { isLarge } = this.props;
76-
77-
this.state = {
78-
isDirty: false,
79-
isOpen: this.isForcedSet() ? this.isForcedOpen() : !!isLarge,
80-
};
77+
this.setForcedByLocation();
8178
}
8279

8380
componentDidUpdate(prevProps: Props): void {
84-
const { fileId, history, isLarge, location }: Props = this.props;
85-
const { fileId: prevFileId, isLarge: prevIsLarge, location: prevLocation }: Props = prevProps;
86-
const { isDirty, isOpen }: State = this.state;
87-
const { state: locationState = {} } = location;
88-
const isForcedSet = this.isForcedSet();
81+
const { fileId, history, location }: Props = this.props;
82+
const { fileId: prevFileId, location: prevLocation }: Props = prevProps;
83+
const { isDirty }: State = this.state;
8984

90-
// User navigated to a different file without ever navigating to a tab
85+
// User navigated to a different file without ever navigating the sidebar
9186
if (!isDirty && fileId !== prevFileId && location.pathname !== '/') {
9287
history.replace({ pathname: '/', state: { silent: true } });
9388
}
9489

95-
// User navigated to a different route or tab for the first time this session
96-
if (!isDirty && location !== prevLocation && !locationState.silent) {
90+
// User navigated or toggled the sidebar intentionally, internally or externally
91+
if (location !== prevLocation && !this.getLocationState('silent')) {
92+
this.setForcedByLocation();
9793
this.setState({ isDirty: true });
9894
}
99-
100-
// User resized their viewport without ever toggling the sidebar open/closed
101-
if (!isForcedSet && isLarge !== prevIsLarge && isLarge !== isOpen) {
102-
this.setState({ isOpen: isLarge });
103-
}
10495
}
10596

106-
/**
107-
* Handle sidebar navigation events
108-
*
109-
* @param {SyntheticEvent} event - The event
110-
* @param {NavigateOptions} options - The navigation options
111-
* @return {void}
112-
*/
113-
handleNavigation = (event: SyntheticEvent<>, { isToggle }: NavigateOptions): void => {
114-
const { isOpen }: State = this.state;
115-
116-
// Persist user preference for all future sessions in this browser
117-
this.isForced(isToggle ? !isOpen : true);
118-
this.setState({ isOpen: this.isForcedOpen() });
119-
};
120-
12197
/**
12298
* Handle version history click
12399
*
@@ -136,6 +112,22 @@ class Sidebar extends React.Component<Props, State> {
136112
history.push(`${history.location.pathname}/versions${fileVersionSlug}`);
137113
};
138114

115+
/**
116+
* Getter for location state properties.
117+
*
118+
* NOTE: Each location on the history stack has its own optional state object that is wholly separate from
119+
* this component's internal state. Values on the location state object can persist even between refreshes
120+
* when using certain history contexts, such as BrowserHistory.
121+
*
122+
* @param key - Optionally get a specific key value from state
123+
* @returns {any} - The location state or state key value
124+
*/
125+
getLocationState(key?: string): any {
126+
const { location } = this.props;
127+
const { state: locationState = {} } = location;
128+
return getProp(locationState, key);
129+
}
130+
139131
/**
140132
* Getter/setter for sidebar forced state
141133
*
@@ -155,7 +147,7 @@ class Sidebar extends React.Component<Props, State> {
155147
* @returns {boolean} - True if the sidebar has been forced open
156148
*/
157149
isForcedOpen(): boolean {
158-
return this.isForced() !== SIDEBAR_FORCE_VALUE_CLOSED;
150+
return this.isForced() === SIDEBAR_FORCE_VALUE_OPEN;
159151
}
160152

161153
/**
@@ -166,6 +158,17 @@ class Sidebar extends React.Component<Props, State> {
166158
return this.isForced() !== null;
167159
}
168160

161+
/**
162+
* Helper to set the local store open state based on the location open state, if defined
163+
*/
164+
setForcedByLocation(): void {
165+
const isLocationOpen: ?boolean = this.getLocationState('open');
166+
167+
if (isLocationOpen !== undefined && isLocationOpen !== null) {
168+
this.isForced(isLocationOpen);
169+
}
170+
}
171+
169172
render() {
170173
const {
171174
activitySidebarProps,
@@ -179,13 +182,14 @@ class Sidebar extends React.Component<Props, State> {
179182
getPreview,
180183
getViewer,
181184
hasAdditionalTabs,
185+
isLarge,
182186
isLoading,
183187
metadataEditors,
184188
metadataSidebarProps,
185189
onVersionChange,
186190
}: Props = this.props;
187191

188-
const { isOpen } = this.state;
192+
const isOpen = this.isForcedSet() ? this.isForcedOpen() : !!isLarge;
189193
const hasActivity = SidebarUtils.canHaveActivitySidebar(this.props);
190194
const hasDetails = SidebarUtils.canHaveDetailsSidebar(this.props);
191195
const hasMetadata = SidebarUtils.shouldRenderMetadataSidebar(this.props, metadataEditors);
@@ -213,7 +217,6 @@ class Sidebar extends React.Component<Props, State> {
213217
hasMetadata={hasMetadata}
214218
hasSkills={hasSkills}
215219
isOpen={isOpen}
216-
onNavigate={this.handleNavigation}
217220
/>
218221
<SidebarPanels
219222
activitySidebarProps={activitySidebarProps}

‎src/elements/content-sidebar/SidebarNav.js‎

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ import IconMagicWand from '../../icons/general/IconMagicWand';
1010
import IconMetadataThick from '../../icons/general/IconMetadataThick';
1111
import IconDocInfo from '../../icons/general/IconDocInfo';
1212
import IconChatRound from '../../icons/general/IconChatRound';
13-
import SidebarToggleButton from '../../components/sidebar-toggle-button';
1413
import messages from '../common/messages';
1514
import { SIDEBAR_NAV_TARGETS } from '../common/interactionTargets';
1615
import SidebarNavButton from './SidebarNavButton';
16+
import SidebarToggle from './SidebarToggle';
1717
import AdditionalTabs from './additional-tabs';
1818
import {
1919
SIDEBAR_VIEW_SKILLS,
@@ -100,16 +100,7 @@ const SidebarNav = ({
100100
{hasAdditionalTabs && <AdditionalTabs key={fileId} tabs={additionalTabs} />}
101101
</div>
102102
<div className="bcs-SidebarNav-footer">
103-
<SidebarToggleButton
104-
data-resin-target={SIDEBAR_NAV_TARGETS.TOGGLE}
105-
data-testid="sidebartoggle"
106-
isOpen={isOpen}
107-
onClick={event => {
108-
if (onNavigate) {
109-
onNavigate(event, { isToggle: true });
110-
}
111-
}}
112-
/>
103+
<SidebarToggle isOpen={isOpen} />
113104
</div>
114105
</div>
115106
);

‎src/elements/content-sidebar/SidebarNavButton.js‎

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ type Props = {
1515
'data-testid'?: string,
1616
children: React.Node,
1717
isOpen?: boolean,
18-
onNavigate?: (SyntheticEvent<>, NavigateOptions) => void,
1918
sidebarView: string,
2019
tooltip: React.Node,
2120
};
@@ -25,7 +24,6 @@ const SidebarNavButton = ({
2524
'data-resin-target': dataResinTarget,
2625
'data-testid': dataTestId,
2726
isOpen,
28-
onNavigate,
2927
sidebarView,
3028
tooltip,
3129
}: Props) => {
@@ -37,6 +35,7 @@ const SidebarNavButton = ({
3735
const isMatch = !!match;
3836
const isActive = () => isMatch && !!isOpen;
3937
const isToggle = isMatch && match.isExact;
38+
const sidebarState = { open: isToggle ? !isOpen : true };
4039

4140
return (
4241
<Tooltip position="middle-left" text={tooltip}>
@@ -47,14 +46,12 @@ const SidebarNavButton = ({
4746
data-resin-target={dataResinTarget}
4847
data-testid={dataTestId}
4948
isActive={isActive}
50-
onClick={event => {
51-
if (onNavigate) {
52-
onNavigate(event, { isToggle });
53-
}
54-
}}
5549
replace={isToggle}
5650
role="tab"
57-
to={sidebarPath}
51+
to={{
52+
pathname: sidebarPath,
53+
state: sidebarState,
54+
}}
5855
type="button"
5956
>
6057
{children}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* @flow strict
3+
* @file Sidebar Toggle component
4+
* @author Box
5+
*/
6+
7+
import * as React from 'react';
8+
import { withRouter, type RouterHistory } from 'react-router-dom';
9+
import SidebarToggleButton from '../../components/sidebar-toggle-button/SidebarToggleButton';
10+
import { SIDEBAR_NAV_TARGETS } from '../common/interactionTargets';
11+
12+
type Props = {
13+
history: RouterHistory,
14+
isOpen?: boolean,
15+
};
16+
17+
const SidebarToggle = ({ history, isOpen }: Props) => {
18+
return (
19+
<SidebarToggleButton
20+
data-resin-target={SIDEBAR_NAV_TARGETS.TOGGLE}
21+
data-testid="sidebartoggle"
22+
isOpen={isOpen}
23+
onClick={event => {
24+
event.preventDefault();
25+
history.replace({ state: { open: !isOpen } });
26+
}}
27+
/>
28+
);
29+
};
30+
31+
export { SidebarToggle as SidebarToggleComponent };
32+
export default withRouter(SidebarToggle);

‎src/elements/content-sidebar/__tests__/Sidebar-test.js‎

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,24 +26,18 @@ describe('elements/content-sidebar/Sidebar', () => {
2626
}));
2727
});
2828

29-
test('should set isOpen if isLarge prop has changed', () => {
30-
const wrapper = getWrapper({ isLarge: true });
31-
32-
expect(wrapper.state('isOpen')).toEqual(true);
33-
34-
wrapper.setProps({ isLarge: false });
35-
36-
expect(wrapper.state('isOpen')).toEqual(false);
37-
});
38-
39-
test('should set isDirty if a user-initiated location change occurred', () => {
29+
test('should update if a user-initiated location change occurred', () => {
4030
const wrapper = getWrapper({ location: { pathname: '/activity' } });
31+
const instance = wrapper.instance();
32+
instance.setForcedByLocation = jest.fn();
4133

4234
expect(wrapper.state('isDirty')).toBe(false);
35+
expect(instance.setForcedByLocation).not.toHaveBeenCalled();
4336

4437
wrapper.setProps({ location: { pathname: '/details' } });
4538

4639
expect(wrapper.state('isDirty')).toBe(true);
40+
expect(instance.setForcedByLocation).toHaveBeenCalled();
4741
});
4842

4943
test('should not set isDirty if an app-initiated location change occurred', () => {
@@ -55,6 +49,24 @@ describe('elements/content-sidebar/Sidebar', () => {
5549

5650
expect(wrapper.state('isDirty')).toBe(false);
5751
});
52+
53+
test('should set the forced open state if the location state is present', () => {
54+
const wrapper = getWrapper({ location: { pathname: '/' } });
55+
const instance = wrapper.instance();
56+
instance.isForced = jest.fn();
57+
58+
wrapper.setProps({ location: { pathname: '/details' } });
59+
expect(instance.isForced).toHaveBeenCalledWith(); // Getter for render
60+
61+
wrapper.setProps({ location: { pathname: '/details/inner', state: { open: true, silent: true } } });
62+
expect(instance.isForced).toHaveBeenCalledWith(); // Getter for render
63+
64+
wrapper.setProps({ location: { pathname: '/', state: { open: true } } });
65+
expect(instance.isForced).toHaveBeenCalledWith(true);
66+
67+
wrapper.setProps({ location: { pathname: '/', state: { open: false } } });
68+
expect(instance.isForced).toHaveBeenCalledWith(false);
69+
});
5870
});
5971

6072
describe('isForced', () => {

‎src/elements/content-sidebar/__tests__/SidebarNav-test.js‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import React from 'react';
22
import { MemoryRouter } from 'react-router-dom';
33
import { mount } from 'enzyme';
4+
import AdditionalTabPlaceholder from '../additional-tabs/AdditionalTabPlaceholder';
5+
import AdditionalTabs from '../additional-tabs';
6+
import AdditionalTabsLoading from '../additional-tabs/AdditionalTabsLoading';
7+
import IconChatRound from '../../../icons/general/IconChatRound';
8+
import IconDocInfo from '../../../icons/general/IconDocInfo';
49
import IconMagicWand from '../../../icons/general/IconMagicWand';
510
import IconMetadataThick from '../../../icons/general/IconMetadataThick';
6-
import IconDocInfo from '../../../icons/general/IconDocInfo';
7-
import IconChatRound from '../../../icons/general/IconChatRound';
8-
import SidebarNavButton from '../SidebarNavButton';
911
import SidebarNav from '../SidebarNav';
12+
import SidebarNavButton from '../SidebarNavButton';
1013

1114
describe('elements/content-sidebar/SidebarNav', () => {
1215
const getWrapper = (props, active = '') =>
@@ -78,10 +81,12 @@ describe('elements/content-sidebar/SidebarNav', () => {
7881

7982
test('should render the additional tabs loading state', () => {
8083
const props = {
81-
hasAdditionalTabs: true,
8284
additionalTabs: [],
85+
hasAdditionalTabs: true,
8386
};
8487
const wrapper = getWrapper(props);
85-
expect(wrapper).toMatchSnapshot();
88+
expect(wrapper.find(AdditionalTabs)).toHaveLength(1);
89+
expect(wrapper.find(AdditionalTabsLoading)).toHaveLength(1);
90+
expect(wrapper.find(AdditionalTabPlaceholder)).toHaveLength(5);
8691
});
8792
});

0 commit comments

Comments
 (0)