-
-
Notifications
You must be signed in to change notification settings - Fork 4.4k
feat(ui): Add "Projects Affected" section in Incidents #13401
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+775
−3
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
6bde037
feat(ui): Add "Projects Affected" section in Incidents [WIP]
billyvg dbdc490
fix project fetching
billyvg 63d2cf9
remove debug, add tests
billyvg 8ee2070
refactor to support loading projects
billyvg dd86664
add project to ProjectsStore for tests
billyvg 589574e
fix tests
billyvg 515372d
fix tests
billyvg 334fb85
remove unused state
billyvg File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,293 @@ | ||
import {memoize, partition, uniqBy} from 'lodash'; | ||
import PropTypes from 'prop-types'; | ||
import React from 'react'; | ||
|
||
import SentryTypes from 'app/sentryTypes'; | ||
import parseLinkHeader from 'app/utils/parseLinkHeader'; | ||
import withApi from 'app/utils/withApi'; | ||
import withProjects from 'app/utils/withProjects'; | ||
|
||
/** | ||
* This is a utility component that should be used to fetch an organization's projects (summary). | ||
* It can either fetch explicit projects (e.g. via slug) or a paginated list of projects. | ||
* These will be passed down to the render prop (`children`). | ||
* | ||
* The legacy way of handling this is that `ProjectSummary[]` is expected to be included in an | ||
* `Organization` as well as being saved to `ProjectsStore`. | ||
*/ | ||
class Projects extends React.Component { | ||
static propTypes = { | ||
api: PropTypes.object.isRequired, | ||
orgId: PropTypes.string.isRequired, | ||
|
||
// List of projects that have we already have summaries for (i.e. from store) | ||
projects: PropTypes.arrayOf(SentryTypes.Project).isRequired, | ||
|
||
// List of slugs to look for summaries for, this can be from `props.projects`, | ||
// otherwise fetch from API | ||
slugs: PropTypes.arrayOf(PropTypes.string), | ||
|
||
// Number of projects to return when not using `props.slugs` | ||
limit: PropTypes.number, | ||
}; | ||
|
||
state = { | ||
fetchedProjects: [], | ||
projectsFromStore: [], | ||
initiallyLoaded: false, | ||
fetching: false, | ||
isIncomplete: null, | ||
hasMore: null, | ||
}; | ||
|
||
componentDidMount() { | ||
const {slugs} = this.props; | ||
|
||
if (slugs && !!slugs.length) { | ||
this.loadSpecificProjects(); | ||
} else { | ||
this.loadAllProjects(); | ||
} | ||
} | ||
|
||
/** | ||
* List of projects that need to be fetched via API | ||
*/ | ||
fetchQueue = new Set(); | ||
|
||
/** | ||
* Memoized function that returns a `Map<project.slug, project>` | ||
*/ | ||
getProjectsMap = memoize( | ||
projects => new Map(projects.map(project => [project.slug, project])) | ||
); | ||
|
||
/** | ||
* When `props.slugs` is included, identifies what projects we already | ||
* have summaries for and what projects need to be fetched from API | ||
*/ | ||
loadSpecificProjects = () => { | ||
const {slugs, projects} = this.props; | ||
|
||
const projectsMap = this.getProjectsMap(projects); | ||
|
||
// Split slugs into projects that are in store and not in store | ||
// (so we can request projects not in store) | ||
const [inStore, notInStore] = partition(slugs, slug => projectsMap.has(slug)); | ||
|
||
// Get the actual summaries of projects that are in store | ||
const projectsFromStore = inStore.map(slug => projectsMap.get(slug)); | ||
|
||
// Add to queue | ||
notInStore.forEach(slug => this.fetchQueue.add(slug)); | ||
|
||
this.setState({ | ||
// placeholders for projects we need to fetch | ||
fetchedProjects: notInStore.map(slug => ({slug})), | ||
initiallyLoaded: true, | ||
projectsFromStore, | ||
}); | ||
|
||
if (!notInStore.length) { | ||
return; | ||
} | ||
|
||
this.fetchSpecificProjects(); | ||
}; | ||
|
||
/** | ||
* These will fetch projects via API (using project slug) provided by `this.fetchQueue` | ||
*/ | ||
fetchSpecificProjects = async () => { | ||
const {api, orgId} = this.props; | ||
|
||
if (!this.fetchQueue.size) { | ||
return; | ||
} | ||
|
||
this.setState({ | ||
fetching: true, | ||
}); | ||
|
||
let projects = []; | ||
let fetchError; | ||
|
||
try { | ||
const {results} = await fetchProjects(api, orgId, { | ||
slugs: Array.from(this.fetchQueue), | ||
}); | ||
projects = results; | ||
} catch (err) { | ||
console.error(err); // eslint-disable-line no-console | ||
fetchError = err; | ||
} | ||
|
||
const projectsMap = this.getProjectsMap(projects); | ||
|
||
// For each item in the fetch queue, lookup the project object and in the case | ||
// where something wrong has happened and we were unable to get project summary from | ||
// the server, just fill in with an object with only the slug | ||
const projectsOrPlaceholder = Array.from(this.fetchQueue).map(slug => | ||
projectsMap.has(slug) ? projectsMap.get(slug) : {slug} | ||
); | ||
|
||
this.setState({ | ||
fetchedProjects: projectsOrPlaceholder, | ||
isIncomplete: this.fetchQueue.size !== projects.length, | ||
initiallyLoaded: true, | ||
fetching: false, | ||
fetchError, | ||
}); | ||
|
||
this.fetchQueue.clear(); | ||
}; | ||
|
||
/** | ||
* If `props.slugs` is not provided, request from API a list of paginated project summaries | ||
* that are in `prop.orgId`. | ||
* | ||
* Provide render prop with results as well as `hasMore` to indicate there are more results. | ||
* Downstream consumers should use this to notify users so that they can e.g. narrow down | ||
* results using search | ||
*/ | ||
loadAllProjects = async () => { | ||
const {api, orgId, limit} = this.props; | ||
|
||
this.setState({ | ||
fetching: true, | ||
}); | ||
|
||
try { | ||
const {results, hasMore} = await fetchProjects(api, orgId, {limit}); | ||
|
||
this.setState({ | ||
fetching: false, | ||
fetchedProjects: results, | ||
initiallyLoaded: true, | ||
hasMore, | ||
}); | ||
} catch (err) { | ||
console.error(err); // eslint-disable-line no-console | ||
|
||
this.setState({ | ||
fetching: false, | ||
fetchedProjects: [], | ||
initiallyLoaded: true, | ||
fetchError: err, | ||
}); | ||
} | ||
}; | ||
|
||
/** | ||
* This is an action provided to consumers for them to update the current projects | ||
* result set using a simple search query. You can allow the new results to either | ||
* be appended or replace the existing results. | ||
* | ||
* @param {String} search The search term to use | ||
* @param {Object} options Options object | ||
* @param {Boolean} options.append Results should be appended to existing list (otherwise, will replace) | ||
*/ | ||
handleSearch = async (search, {append} = {}) => { | ||
const {api, orgId, limit} = this.props; | ||
|
||
this.setState({fetching: true}); | ||
|
||
try { | ||
const {results, hasMore} = await fetchProjects(api, orgId, {search, limit}); | ||
|
||
this.setState(state => { | ||
let fetchedProjects; | ||
if (append) { | ||
// Remove duplicates | ||
fetchedProjects = uniqBy( | ||
[...state.fetchedProjects, ...results], | ||
({slug}) => slug | ||
); | ||
} else { | ||
fetchedProjects = results; | ||
} | ||
return { | ||
fetchedProjects, | ||
hasMore, | ||
fetching: false, | ||
}; | ||
}); | ||
} catch (err) { | ||
console.error(err); // eslint-disable-line no-console | ||
|
||
this.setState({ | ||
fetching: false, | ||
fetchError: err, | ||
}); | ||
} | ||
}; | ||
|
||
render() { | ||
const {slugs, children} = this.props; | ||
|
||
return children({ | ||
// We want to make sure that at the minimum, we return a list of objects with only `slug` | ||
// while we load actual project data | ||
projects: this.state.initiallyLoaded | ||
? [...this.state.fetchedProjects, ...this.state.projectsFromStore] | ||
: (slugs && slugs.map(slug => ({slug}))) || [], | ||
|
||
// This is set when we fail to find some slugs from both store and API | ||
isIncomplete: this.state.isIncomplete, | ||
|
||
// This is state for when fetching data from API | ||
fetching: this.state.fetching, | ||
|
||
// Project results (from API) are paginated and there are more projects | ||
// that are not in the initial queryset | ||
hasMore: this.state.hasMore, | ||
|
||
// Calls API and searches for project, accepts a callback function with signature: | ||
// | ||
// fn(searchTerm, {append: bool}) | ||
onSearch: this.handleSearch, | ||
}); | ||
} | ||
} | ||
|
||
export default withProjects(withApi(Projects)); | ||
|
||
async function fetchProjects(api, orgId, {slugs, search, limit} = {}) { | ||
const query = {}; | ||
|
||
if (slugs && slugs.length) { | ||
query.query = slugs.map(slug => `slug:${slug}`).join(' '); | ||
} | ||
|
||
if (search) { | ||
query.query = `${query.query ? `${query.query} ` : ''}${search}`; | ||
} | ||
|
||
// "0" shouldn't be a valid value, so this check is fine | ||
if (limit) { | ||
query.per_page = limit; | ||
} | ||
|
||
let hasMore = false; | ||
const [results, _, xhr] = await api.requestPromise( | ||
`/organizations/${orgId}/projects/`, | ||
{ | ||
includeAllArgs: true, | ||
query, | ||
} | ||
); | ||
|
||
const pageLinks = xhr && xhr.getResponseHeader('Link'); | ||
|
||
if (pageLinks) { | ||
const paginationObject = parseLinkHeader(pageLinks); | ||
hasMore = | ||
paginationObject && | ||
(paginationObject.next.results || paginationObject.previous.results); | ||
} | ||
|
||
return { | ||
results, | ||
hasMore, | ||
}; | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this component has
withProjects()
on it how likely will it be that we won't have all projects in the client?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As it is now... very unlikely it does not have all projects.