diff --git a/src/sentry/static/sentry/app/components/groupList.jsx b/src/sentry/static/sentry/app/components/groupList.jsx index 10cc460d44aae5..5aa0ca3ddea82f 100644 --- a/src/sentry/static/sentry/app/components/groupList.jsx +++ b/src/sentry/static/sentry/app/components/groupList.jsx @@ -163,7 +163,6 @@ const GroupList = createReactClass({ key={id} id={id} orgId={orgId} - projectId={project.slug} canSelect={this.props.canSelectGroups} /> ); diff --git a/src/sentry/static/sentry/app/components/stream/group.jsx b/src/sentry/static/sentry/app/components/stream/group.jsx index 7da62e4ef5c48e..98c8c5d504226f 100644 --- a/src/sentry/static/sentry/app/components/stream/group.jsx +++ b/src/sentry/static/sentry/app/components/stream/group.jsx @@ -25,7 +25,6 @@ const StreamGroup = createReactClass({ propTypes: { id: PropTypes.string.isRequired, orgId: PropTypes.string.isRequired, - projectId: PropTypes.string.isRequired, statsPeriod: PropTypes.string.isRequired, canSelect: PropTypes.bool, query: PropTypes.string, @@ -87,7 +86,8 @@ const StreamGroup = createReactClass({ render() { const {data} = this.state; - const {id, orgId, projectId, query, hasGuideAnchor, canSelect} = this.props; + const {id, orgId, query, hasGuideAnchor, canSelect} = this.props; + const projectId = data.project.slug; return ( diff --git a/src/sentry/static/sentry/app/routes.jsx b/src/sentry/static/sentry/app/routes.jsx index 78a929ff25cc9d..64bb60ee830539 100644 --- a/src/sentry/static/sentry/app/routes.jsx +++ b/src/sentry/static/sentry/app/routes.jsx @@ -754,7 +754,6 @@ function routes() { - @@ -767,7 +766,6 @@ function routes() { component={errorHandler(LazyLoad)} /> - @@ -777,12 +775,10 @@ function routes() { - - @@ -795,14 +791,38 @@ function routes() { component={errorHandler(LazyLoad)} /> - + + import(/* webpackChunkName: "OrganizationStreamContainer" */ './views/organizationStream/container')} + component={errorHandler(LazyLoad)} + > + + import(/* webpackChunkName: "OrganizationStreamOverview" */ './views/organizationStream/overview')} + component={errorHandler(LazyLoad)} + /> + + {/* Once org issues is complete, these routes can be nested under + /organizations/:orgId/issues */} + + + import(/* webpackChunkName: "OrganizationUserFeedback" */ './views/userFeedback/organizationUserFeedback')} component={errorHandler(LazyLoad)} /> - @@ -821,14 +841,12 @@ function routes() { component={errorHandler(LazyLoad)} /> - import(/* webpackChunkName: "TeamCreate" */ './views/teamCreate')} component={errorHandler(LazyLoad)} /> - {hooksOrgRoutes} @@ -861,25 +879,10 @@ function routes() { - - - - - - + + + + + {t('Issues')} + + + {children} + + + ); + } +} +export default withRouter(withOrganization(OrganizationStreamContainer)); +export {OrganizationStreamContainer}; diff --git a/src/sentry/static/sentry/app/views/organizationStream/overview.jsx b/src/sentry/static/sentry/app/views/organizationStream/overview.jsx new file mode 100644 index 00000000000000..57a1973cbfbf6a --- /dev/null +++ b/src/sentry/static/sentry/app/views/organizationStream/overview.jsx @@ -0,0 +1,482 @@ +import {browserHistory} from 'react-router'; +import {isEqual} from 'lodash'; +import Cookies from 'js-cookie'; +import React from 'react'; +import Reflux from 'reflux'; +import classNames from 'classnames'; +import createReactClass from 'create-react-class'; +import qs from 'query-string'; + +import {Panel, PanelBody} from 'app/components/panels'; +import {analytics} from 'app/utils/analytics'; +import {t, tct} from 'app/locale'; +import ApiMixin from 'app/mixins/apiMixin'; +import ConfigStore from 'app/stores/configStore'; +import GroupStore from 'app/stores/groupStore'; +import LoadingError from 'app/components/loadingError'; +import LoadingIndicator from 'app/components/loadingIndicator'; +import Pagination from 'app/components/pagination'; +import SentryTypes from 'app/sentryTypes'; +import StreamGroup from 'app/components/stream/group'; +import parseApiError from 'app/utils/parseApiError'; +import parseLinkHeader from 'app/utils/parseLinkHeader'; +import utils from 'app/utils'; +import withOrganization from 'app/utils/withOrganization'; + +const MAX_ITEMS = 25; +const DEFAULT_SORT = 'date'; +const DEFAULT_STATS_PERIOD = '24h'; +const STATS_PERIODS = new Set(['14d', '24h']); + +const OrganizationStream = createReactClass({ + displayName: 'OrganizationStream', + + propTypes: { + organization: SentryTypes.Organization, + }, + + mixins: [Reflux.listenTo(GroupStore, 'onGroupChange'), ApiMixin], + + getInitialState() { + let realtimeActiveCookie = Cookies.get('realtimeActive'); + let realtimeActive = + typeof realtimeActiveCookie === 'undefined' + ? false + : realtimeActiveCookie === 'true'; + + let currentQuery = this.props.location.query || {}; + let sort = 'sort' in currentQuery ? currentQuery.sort : DEFAULT_SORT; + + let statsPeriod = STATS_PERIODS.has(currentQuery.statsPeriod) + ? currentQuery.statsPeriod + : DEFAULT_STATS_PERIOD; + + return { + groupIds: [], + isDefaultSearch: false, + loading: false, + selectAllActive: false, + multiSelected: false, + anySelected: false, + statsPeriod, + realtimeActive, + pageLinks: '', + queryCount: null, + error: false, + query: currentQuery.query || '', + sort, + tagsLoading: true, + tags: [], + isSidebarVisible: false, + processingIssues: null, + }; + }, + + componentWillMount() { + this._streamManager = new utils.StreamManager(GroupStore); + this._poller = new utils.CursorPoller({ + success: this.onRealtimePoll, + }); + + if (!this.state.loading) { + this.fetchData(); + } + }, + + componentWillReceiveProps(nextProps) { + // We are using qs.parse with location.search since this.props.location.query + // returns the same value as nextProps.location.query + let currentSearchTerm = qs.parse(this.props.location.search); + let nextSearchTerm = qs.parse(nextProps.location.search); + + let searchTermChanged = !isEqual(currentSearchTerm, nextSearchTerm); + + if (searchTermChanged) { + this.setState(this.getQueryState(nextProps), this.fetchData); + } + }, + + componentDidUpdate(prevProps, prevState) { + if (prevState.realtimeActive !== this.state.realtimeActive) { + // User toggled realtime button + if (this.state.realtimeActive) { + this.resumePolling(); + } else { + this._poller.disable(); + } + } + }, + + componentWillUnmount() { + this._poller.disable(); + GroupStore.reset(); + }, + + getAccess() { + return new Set(this.props.organization.access); + }, + + getQueryState(props) { + let currentQuery = props.location.query || {}; + let hasQuery = 'query' in currentQuery; + let sort = 'sort' in currentQuery ? currentQuery.sort : DEFAULT_SORT; + let statsPeriod = STATS_PERIODS.has(currentQuery.statsPeriod) + ? currentQuery.statsPeriod + : DEFAULT_STATS_PERIOD; + + let newState = { + sort, + statsPeriod, + query: hasQuery ? currentQuery.query : '', + isDefaultSearch: false, + }; + newState.loading = false; + + return newState; + }, + + hasQuery(props) { + props = props || this.props; + let currentQuery = props.location.query || {}; + return 'query' in currentQuery; + }, + + fetchData() { + GroupStore.loadInitialData([]); + + this.setState({ + loading: true, + queryCount: null, + error: false, + }); + + let url = this.getGroupListEndpoint(); + let query = qs.parse(this.props.location.query); + + let requestParams = { + ...query, + limit: MAX_ITEMS, + sort: this.state.sort, + statsPeriod: this.state.statsPeriod, + shortIdLookup: '1', + environment: this.state.environment, + }; + + let currentQuery = this.props.location.query || {}; + if ('cursor' in currentQuery) { + requestParams.cursor = currentQuery.cursor; + } + + if (this.lastRequest) { + this.lastRequest.cancel(); + } + + this._poller.disable(); + + this.lastRequest = this.api.request(url, { + method: 'GET', + data: requestParams, + success: (data, ignore, jqXHR) => { + // if this is a direct hit, we redirect to the intended result directly. + // we have to use the project slug from the result data instead of the + // the current props one as the shortIdLookup can return results for + // different projects. + if (jqXHR.getResponseHeader('X-Sentry-Direct-Hit') === '1') { + if (data && data[0].matchingEventId) { + let {project, id, matchingEventId} = data[0]; + let redirect = `/${this.props.params + .orgId}/${project.slug}/issues/${id}/events/${matchingEventId}/`; + + // TODO set environment for the requested issue. + browserHistory.replace(redirect); + return; + } + } + + this._streamManager.push(data); + + let queryCount = jqXHR.getResponseHeader('X-Hits'); + let queryMaxCount = jqXHR.getResponseHeader('X-Max-Hits'); + + this.setState({ + error: false, + loading: false, + query, + queryCount: + typeof queryCount !== 'undefined' ? parseInt(queryCount, 10) || 0 : 0, + queryMaxCount: + typeof queryMaxCount !== 'undefined' ? parseInt(queryMaxCount, 10) || 0 : 0, + pageLinks: jqXHR.getResponseHeader('Link'), + }); + }, + error: err => { + this.setState({ + error: parseApiError(err), + loading: false, + }); + }, + complete: jqXHR => { + this.lastRequest = null; + + this.resumePolling(); + }, + }); + }, + + resumePolling() { + if (!this.state.pageLinks) return; + + // Only resume polling if we're on the first page of results + let links = parseLinkHeader(this.state.pageLinks); + if (links && !links.previous.results && this.state.realtimeActive) { + this._poller.setEndpoint(links.previous.href); + this._poller.enable(); + } + }, + + getGroupListEndpoint() { + let params = this.props.params; + + return '/organizations/' + params.orgId + '/issues/'; + }, + + onRealtimeChange(realtime) { + Cookies.set('realtimeActive', realtime.toString()); + this.setState({ + realtimeActive: realtime, + }); + }, + + onSelectStatsPeriod(period) { + if (period != this.state.statsPeriod) { + // TODO(dcramer): all charts should now suggest "loading" + this.setState( + { + statsPeriod: period, + }, + function() { + this.transitionTo(); + } + ); + } + }, + + onRealtimePoll(data, links) { + this._streamManager.unshift(data); + if (!utils.valueIsEqual(this.state.pageLinks, links, true)) { + this.setState({ + pageLinks: links, + }); + } + }, + + onGroupChange() { + let groupIds = this._streamManager.getAllItems().map(item => item.id); + if (!utils.valueIsEqual(groupIds, this.state.groupIds)) { + this.setState({ + groupIds, + }); + } + }, + + onSearch(query) { + if (query === this.state.query) { + // if query is the same, just re-fetch data + this.fetchData(); + } else { + this.setState( + { + query, + }, + this.transitionTo + ); + } + }, + + onSortChange(sort) { + this.setState( + { + sort, + }, + this.transitionTo + ); + }, + + onSidebarToggle() { + let {organization} = this.props; + this.setState({ + isSidebarVisible: !this.state.isSidebarVisible, + }); + analytics('issue.search_sidebar_clicked', { + org_id: parseInt(organization.id, 10), + }); + }, + + /** + * Returns true if all results in the current query are visible/on this page + */ + allResultsVisible() { + if (!this.state.pageLinks) return false; + + let links = parseLinkHeader(this.state.pageLinks); + return links && !links.previous.results && !links.next.results; + }, + + transitionTo() { + let queryParams = {}; + + if (this.props.location.query.environment) { + queryParams.environment = this.props.location.query.environment; + } + + queryParams.query = this.state.query; + + if (this.state.sort !== DEFAULT_SORT) { + queryParams.sort = this.state.sort; + } + + if (this.state.statsPeriod !== DEFAULT_STATS_PERIOD) { + queryParams.statsPeriod = this.state.statsPeriod; + } + + let params = this.props.params; + + let path = `/${params.orgId}/issues/`; + browserHistory.push({ + pathname: path, + query: queryParams, + }); + }, + + renderGroupNodes(ids, statsPeriod) { + // Restrict this guide to only show for new users (joined<30 days) and add guide anhor only to the first issue + let userDateJoined = new Date(ConfigStore.get('user').dateJoined); + let dateCutoff = new Date(); + dateCutoff.setDate(dateCutoff.getDate() - 30); + + let topIssue = ids[0]; + + let {orgId} = this.props.params; + let groupNodes = ids.map(id => { + let hasGuideAnchor = userDateJoined > dateCutoff && id === topIssue; + return ( + + ); + }); + return {groupNodes}; + }, + + renderEmpty() { + const {environment} = this.state; + const message = environment + ? tct('Sorry no events match your filters in the [env] environment.', { + env: environment.displayName, + }) + : t('Sorry, no events match your filters.'); + + // TODO(lyn): Extract empty state to a separate component + return ( +
+ +

{message}

+
+ ); + }, + + renderLoading() { + return ; + }, + + renderStreamBody() { + let body; + + if (this.state.loading) { + body = this.renderLoading(); + } else if (this.state.error) { + body = ; + } else if (this.state.groupIds.length > 0) { + body = this.renderGroupNodes(this.state.groupIds, this.state.statsPeriod); + } else { + body = this.renderEmpty(); + } + return body; + }, + + render() { + // global loading + if (this.state.loading) { + return this.renderLoading(); + } + let params = this.props.params; + let classes = ['stream-row']; + if (this.state.isSidebarVisible) classes.push('show-sidebar'); + let {orgId} = this.props.params; + let access = this.getAccess(); + + // In the project mode this reads from the project feature. + // There is no analogous property for organizations yet. + let hasReleases = false; + let latestRelease = ''; + + return ( +
+
+ + + + {this.renderStreamBody()} + + +
+ +
+ ); + }, +}); + +// Placeholder components to keep pull requests manageable. +const StreamFilters = props =>

Stream filters are coming soon

; +const StreamActions = props =>

Stream actions are coming soon

; +const StreamSidebar = props =>

Stream sidebar is coming soon

; + +export default withOrganization(OrganizationStream); +export {OrganizationStream}; diff --git a/src/sentry/static/sentry/app/views/stream/stream.jsx b/src/sentry/static/sentry/app/views/stream/stream.jsx index e93fdc1bc0c557..82adae5fe0f35b 100644 --- a/src/sentry/static/sentry/app/views/stream/stream.jsx +++ b/src/sentry/static/sentry/app/views/stream/stream.jsx @@ -649,7 +649,7 @@ const Stream = createReactClass({ let topIssue = ids[0]; - let {orgId, projectId} = this.props.params; + let {orgId} = this.props.params; let groupNodes = ids.map(id => { let hasGuideAnchor = userDateJoined > dateCutoff && id === topIssue; return ( @@ -657,7 +657,6 @@ const Stream = createReactClass({ key={id} id={id} orgId={orgId} - projectId={projectId} statsPeriod={statsPeriod} query={this.state.query} hasGuideAnchor={hasGuideAnchor} diff --git a/tests/js/spec/components/__snapshots__/streamGroup.spec.jsx.snap b/tests/js/spec/components/__snapshots__/streamGroup.spec.jsx.snap index a30702c3f9cb53..b2d1e1166e3391 100644 --- a/tests/js/spec/components/__snapshots__/streamGroup.spec.jsx.snap +++ b/tests/js/spec/components/__snapshots__/streamGroup.spec.jsx.snap @@ -67,7 +67,7 @@ exports[`StreamGroup renders with anchors 1`] = ` } includeLink={true} orgId="orgId" - projectId="projectId" + projectId="test" /> @@ -253,7 +252,6 @@ exports[`Stream toggles environment select all environments 1`] = ` id="1" key="1" orgId="org-slug" - projectId="project-slug" query="is:unresolved" statsPeriod="24h" />