Skip to content

Commit 15f98ba

Browse files
authored
fix(ui): Change Issue details to not force global selection values [SEN-658] (#13554)
We save last selected projects + envs (+ dates) in local storage. High up in the component tree, we initialize our global selection store with these values from local storage. This means when we view issue details with a direct URL that does not contain a project or environment, it will load values from the store. So you could end up in a weird state if it tries to load last used environment. This makes sure that 1) the component tree for GroupDetails prop drills `environments` so that it does not directly read from the store and 2) does not pass down the environments value from the store for the component that uses `withGlobalSelection` (controlled by a prop). This will maintain the current behavior for components that do not specify the `disableLoadFromStore` prop on the `withGlobalSelection` HoC. So if you were to navigate from issue details back to issue stream, it will have values from store. Another issue this fixes is that when GlobalSelectionHeader changed params, it would update ALL url params (e.g. projects, envs, dates) - which was not a problem before because these values would usually be synced, but because we initially do not use values from store, it is a bit out of sync so if you were to change environments, it would also incorrectly overwrite projects and dates as well.
1 parent e334d09 commit 15f98ba

File tree

7 files changed

+140
-34
lines changed

7 files changed

+140
-34
lines changed

src/sentry/static/sentry/app/components/organizations/globalSelectionHeader/index.jsx

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ class GlobalSelectionHeader extends React.Component {
5050
*/
5151
forceProject: SentryTypes.Project,
5252

53+
/**
54+
* If true, do not initially update URL with values from store
55+
*/
56+
disableLoadFromStore: PropTypes.bool,
57+
5358
/**
5459
* Currently selected values(s)
5560
*/
@@ -109,8 +114,7 @@ class GlobalSelectionHeader extends React.Component {
109114
const hasMultipleProjectFeature = this.hasMultipleProjectSelection();
110115

111116
const stateFromRouter = getStateFromQuery(location.query);
112-
// We should update store if there are any relevant URL parameters when component
113-
// is mounted
117+
// We should update store if there are any relevant URL parameters when component is mounted
114118
if (Object.values(stateFromRouter).some(i => !!i)) {
115119
if (!stateFromRouter.start && !stateFromRouter.end && !stateFromRouter.period) {
116120
stateFromRouter.period = DEFAULT_STATS_PERIOD;
@@ -143,6 +147,10 @@ class GlobalSelectionHeader extends React.Component {
143147
// update URL parameters to reflect current store
144148
const {datetime, environments, projects} = selection;
145149

150+
if (this.props.disableLoadFromStore) {
151+
return;
152+
}
153+
146154
if (hasMultipleProjectFeature || projects.length === 1) {
147155
updateParamsWithoutHistory(
148156
{project: projects, environment: environments, ...datetime},
@@ -166,7 +174,7 @@ class GlobalSelectionHeader extends React.Component {
166174
}
167175

168176
// Update if URL parameters change
169-
if (this.didQueryChange(this.props, nextProps)) {
177+
if (this.changedQueryKeys(this.props, nextProps).length > 0) {
170178
return true;
171179
}
172180

@@ -219,35 +227,57 @@ class GlobalSelectionHeader extends React.Component {
219227
return new Set(this.props.organization.features).has('global-views');
220228
};
221229

222-
didQueryChange = (prevProps, nextProps) => {
230+
/**
231+
* Identifies if query string has changed (with query params that this component cares about)
232+
*
233+
*
234+
* @return {String[]} Returns `false` if did not change, otherwise return an array of params that have changed
235+
*/
236+
changedQueryKeys = (prevProps, nextProps) => {
223237
const urlParamKeys = Object.values(URL_PARAM);
224238
const prevQuery = pick(prevProps.location.query, urlParamKeys);
225239
const nextQuery = pick(nextProps.location.query, urlParamKeys);
226240

227241
// If no next query is specified keep the previous global selection values
228242
if (Object.keys(prevQuery).length === 0 && Object.keys(nextQuery).length === 0) {
229-
return false;
243+
return [];
230244
}
231245

232-
return !isEqual(prevQuery, nextQuery);
246+
const changedKeys = Object.values(urlParamKeys).filter(
247+
key => !isEqual(prevQuery[key], nextQuery[key])
248+
);
249+
250+
return changedKeys;
233251
};
234252

235253
updateStoreIfChange = (prevProps, nextProps) => {
236254
// Don't do anything if query parameters have not changed
237255
//
238256
// e.g. if selection store changed, don't trigger more actions
239257
// to update global selection store (otherwise we'll get recursive updates)
240-
if (!this.didQueryChange(prevProps, nextProps)) {
258+
const changedKeys = this.changedQueryKeys(prevProps, nextProps);
259+
260+
if (!changedKeys.length) {
241261
return;
242262
}
243263

244264
const {project, environment, period, start, end, utc} = getStateFromQuery(
245265
nextProps.location.query
246266
);
247267

248-
updateDateTime({start, end, period, utc});
249-
updateEnvironments(environment || []);
250-
updateProjects(project || []);
268+
if (changedKeys.includes(URL_PARAM.PROJECT)) {
269+
updateProjects(project || []);
270+
}
271+
if (changedKeys.includes(URL_PARAM.ENVIRONMENT)) {
272+
updateEnvironments(environment || []);
273+
}
274+
if (
275+
[URL_PARAM.START, URL_PARAM.END, URL_PARAM.UTC, URL_PARAM.PERIOD].find(key =>
276+
changedKeys.includes(key)
277+
)
278+
) {
279+
updateDateTime({start, end, period, utc});
280+
}
251281
};
252282

253283
// Returns `router` from props if `hasCustomRouting` property is false

src/sentry/static/sentry/app/utils/withGlobalSelection.jsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
import PropTypes from 'prop-types';
12
import React from 'react';
23
import Reflux from 'reflux';
34
import createReactClass from 'create-react-class';
45

5-
import getDisplayName from 'app/utils/getDisplayName';
66
import GlobalSelectionStore from 'app/stores/globalSelectionStore';
7+
import getDisplayName from 'app/utils/getDisplayName';
78

89
/**
910
* Higher order component that uses GlobalSelectionStore and provides the
@@ -12,10 +13,17 @@ import GlobalSelectionStore from 'app/stores/globalSelectionStore';
1213
const withGlobalSelection = WrappedComponent =>
1314
createReactClass({
1415
displayName: `withGlobalSelection(${getDisplayName(WrappedComponent)})`,
16+
propTypes: {
17+
// Does not initially load values from the store
18+
// However any following updates to store should work
19+
disableLoadFromStore: PropTypes.bool,
20+
},
1521
mixins: [Reflux.listenTo(GlobalSelectionStore, 'onUpdate')],
1622
getInitialState() {
1723
return {
18-
selection: GlobalSelectionStore.get(),
24+
selection: this.props.disableLoadFromStore
25+
? {projects: [], environments: [], datetime: {}}
26+
: GlobalSelectionStore.get(),
1927
};
2028
},
2129

src/sentry/static/sentry/app/views/organizationGroupDetails/groupDetails.jsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ const GroupDetails = createReactClass({
3333
environments: PropTypes.arrayOf(PropTypes.string),
3434
enableSnuba: PropTypes.bool,
3535
showGlobalHeader: PropTypes.bool,
36+
37+
// This gets passed to global selection header
38+
disableLoadFromStore: PropTypes.bool,
3639
},
3740

3841
childContextTypes: {
@@ -229,7 +232,7 @@ const GroupDetails = createReactClass({
229232
},
230233

231234
render() {
232-
const {organization, showGlobalHeader} = this.props;
235+
const {organization, disableLoadFromStore, showGlobalHeader} = this.props;
233236
const {group, project, loading} = this.state;
234237

235238
if (this.state.error) {
@@ -251,6 +254,7 @@ const GroupDetails = createReactClass({
251254
<React.Fragment>
252255
{showGlobalHeader && (
253256
<GlobalSelectionHeader
257+
disableLoadFromStore={disableLoadFromStore}
254258
organization={organization}
255259
forceProject={project}
256260
showDateSelector={false}

src/sentry/static/sentry/app/views/organizationGroupDetails/groupEventDetails.jsx

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import ResolutionBox from 'app/components/resolutionBox';
1313
import MutedBox from 'app/components/mutedBox';
1414
import withApi from 'app/utils/withApi';
1515
import withOrganization from 'app/utils/withOrganization';
16-
import withGlobalSelection from 'app/utils/withGlobalSelection';
1716
import fetchSentryAppInstallations from 'app/utils/fetchSentryAppInstallations';
1817
import {fetchSentryAppComponents} from 'app/actionCreators/sentryAppComponents';
1918
import OrganizationEnvironmentsStore from 'app/stores/organizationEnvironmentsStore';
@@ -178,17 +177,17 @@ class GroupEventDetails extends React.Component {
178177
}
179178
}
180179

181-
export {GroupEventDetails};
180+
function GroupEventDetailsContainer(props) {
181+
const environments = OrganizationEnvironmentsStore.getActive().filter(env =>
182+
props.environments.includes(env.name)
183+
);
184+
185+
return <GroupEventDetails {...props} environments={environments} />;
186+
}
182187

183-
export default withApi(
184-
withOrganization(
185-
withGlobalSelection(props => {
186-
const {selection, ...otherProps} = props;
187-
const environments = OrganizationEnvironmentsStore.getActive().filter(env =>
188-
selection.environments.includes(env.name)
189-
);
190-
191-
return <GroupEventDetails {...otherProps} environments={environments} />;
192-
})
193-
)
194-
);
188+
GroupEventDetailsContainer.propTypes = {
189+
environments: PropTypes.arrayOf(SentryTypes.Environment).isRequired,
190+
};
191+
192+
export default withApi(withOrganization(GroupEventDetailsContainer));
193+
export {GroupEventDetails};

src/sentry/static/sentry/app/views/organizationGroupDetails/index.jsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,23 @@ class OrganizationGroupDetails extends React.Component {
2121
}
2222

2323
render() {
24-
// eslint-disable-next-line no-unused-vars
2524
const {selection, ...props} = this.props;
2625

2726
return (
2827
<GroupDetails
2928
environments={selection.environments}
30-
enableSnuba={true}
31-
showGlobalHeader={true}
29+
enableSnuba
30+
showGlobalHeader
3231
{...props}
3332
/>
3433
);
3534
}
3635
}
3736

38-
export default withOrganization(withGlobalSelection(OrganizationGroupDetails));
37+
const OrganizationGroupDetailsHoC = withOrganization(
38+
withGlobalSelection(OrganizationGroupDetails)
39+
);
40+
41+
export default function OrganizationGroupDetailsContainer(props) {
42+
return <OrganizationGroupDetailsHoC disableLoadFromStore {...props} />;
43+
}

tests/js/spec/components/organizations/globalSelectionHeader.spec.jsx

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,20 @@ describe('GlobalSelectionHeader', function() {
111111
})
112112
);
113113

114+
// component will initially try to sync router + stores
115+
expect(globalActions.updateDateTime).toHaveBeenCalledWith({
116+
period: '7d',
117+
utc: null,
118+
start: null,
119+
end: null,
120+
});
121+
expect(globalActions.updateProjects).toHaveBeenCalledWith([]);
122+
expect(globalActions.updateEnvironments).toHaveBeenCalledWith([]);
123+
124+
globalActions.updateDateTime.mockClear();
125+
globalActions.updateProjects.mockClear();
126+
globalActions.updateEnvironments.mockClear();
127+
114128
wrapper.setContext(
115129
changeQuery(routerContext, {
116130
statsPeriod: '21d',
@@ -125,8 +139,10 @@ describe('GlobalSelectionHeader', function() {
125139
start: null,
126140
end: null,
127141
});
128-
expect(globalActions.updateProjects).toHaveBeenCalledWith([]);
129-
expect(globalActions.updateEnvironments).toHaveBeenCalledWith([]);
142+
143+
// These should not be called because they have not changed, only date has changed
144+
expect(globalActions.updateProjects).not.toHaveBeenCalled();
145+
expect(globalActions.updateEnvironments).not.toHaveBeenCalled();
130146

131147
expect(GlobalSelectionStore.get()).toEqual({
132148
datetime: {
@@ -248,6 +264,51 @@ describe('GlobalSelectionHeader', function() {
248264
});
249265
});
250266

267+
it('updates store when there are no query params in URL and `disableLoadFromStore` is false', function() {
268+
const initializationObj = initializeOrg({
269+
organization: {
270+
features: ['global-views'],
271+
},
272+
router: {
273+
params: {orgId: 'org-slug'}, // we need this to be set to make sure org in context is same as current org in URL
274+
location: {query: {project: [1, 2]}},
275+
},
276+
});
277+
278+
mount(
279+
<GlobalSelectionHeader organization={initializationObj.organization} />,
280+
initializationObj.routerContext
281+
);
282+
283+
expect(globalActions.updateProjects).toHaveBeenCalledWith([1, 2]);
284+
expect(globalActions.updateEnvironments).toHaveBeenCalledWith([]);
285+
expect(globalActions.updateDateTime).toHaveBeenCalled();
286+
});
287+
288+
it('does not update store when there are no query params in URL and `disableLoadFromStore` is true', function() {
289+
const initializationObj = initializeOrg({
290+
organization: {
291+
features: ['global-views'],
292+
},
293+
router: {
294+
params: {orgId: 'org-slug'}, // we need this to be set to make sure org in context is same as current org in URL
295+
location: {query: {}},
296+
},
297+
});
298+
299+
mount(
300+
<GlobalSelectionHeader
301+
organization={initializationObj.organization}
302+
disableLoadFromStore={true}
303+
/>,
304+
initializationObj.routerContext
305+
);
306+
307+
expect(globalActions.updateProjects).not.toHaveBeenCalled();
308+
expect(globalActions.updateEnvironments).not.toHaveBeenCalled();
309+
expect(globalActions.updateDateTime).not.toHaveBeenCalled();
310+
});
311+
251312
describe('Single project selection mode', function() {
252313
it('selects first project if more than one is requested', function() {
253314
const initializationObj = initializeOrg({

tests/js/spec/views/organizationDashboard/overviewDashboard.spec.jsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,6 @@ describe('OverviewDashboard', function() {
9292

9393
createWrapper(dashboardData);
9494

95-
// TODO(billy): Figure out why releases gets called twice
9695
expect(discoverMock).toHaveBeenCalledTimes(4);
9796

9897
expect(releasesMock).toHaveBeenCalledTimes(1);

0 commit comments

Comments
 (0)