Files
2024-02-15 17:01:50 +00:00

553 lines
15 KiB
TypeScript

import {Component} from 'react';
import memoize from 'lodash/memoize';
import partition from 'lodash/partition';
import uniqBy from 'lodash/uniqBy';
import type {Client} from 'sentry/api';
import ProjectsStore from 'sentry/stores/projectsStore';
import type {AvatarProject, Project} from 'sentry/types';
import {defined} from 'sentry/utils';
import getDaysSinceDate from 'sentry/utils/getDaysSinceDate';
import parseLinkHeader from 'sentry/utils/parseLinkHeader';
import type RequestError from 'sentry/utils/requestError/requestError';
import withApi from 'sentry/utils/withApi';
import withProjects from 'sentry/utils/withProjects';
type ProjectPlaceholder = AvatarProject;
type State = {
/**
* The error that occurred if fetching failed
*/
fetchError: null | RequestError;
/**
* Projects from API
*/
fetchedProjects: Project[] | ProjectPlaceholder[];
/**
* This is state for when fetching data from API
*/
fetching: boolean;
/**
* Project results (from API) are paginated and there are more projects
* that are not in the initial queryset
*/
hasMore: null | boolean;
/**
* Reflects whether or not the initial fetch for the requested projects
* was fulfilled
*/
initiallyLoaded: boolean;
/**
* This is set when we fail to find some slugs from both store and API
*/
isIncomplete: null | boolean;
prevSearch: null | string;
/**
* Projects fetched from store
*/
projectsFromStore: Project[];
nextCursor?: null | string;
};
type RenderProps = {
/**
* Calls API and searches for project, accepts a callback function with signature:
* fn(searchTerm, {append: bool})
*/
onSearch: (searchTerm: string, options: {append: boolean}) => void;
/**
* 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: Project[] | ProjectPlaceholder[];
} & Pick<
State,
'isIncomplete' | 'fetching' | 'hasMore' | 'initiallyLoaded' | 'fetchError'
>;
type RenderFunc = (props: RenderProps) => React.ReactNode;
type DefaultProps = {
/**
* If slugs is passed, forward placeholder objects with slugs while fetching
*/
passthroughPlaceholderProject?: boolean;
};
type Props = {
api: Client;
children: RenderFunc;
/**
* Organization slug
*/
orgId: string;
/**
* List of projects that have we already have summaries for (i.e. from store)
*/
projects: Project[];
/**
* Whether to fetch all the projects in the organization of which the user
* has access to
*/
allProjects?: boolean;
/**
* Number of projects to return when not using `props.slugs`
*/
limit?: number;
/**
* List of project ids to look for summaries for, this can be from `props.projects`,
* otherwise fetch from API
*/
projectIds?: number[];
/**
* List of slugs to look for summaries for, this can be from `props.projects`,
* otherwise fetch from API
*/
slugs?: string[];
} & DefaultProps;
class BaseProjects extends Component<Props, State> {
static defaultProps: DefaultProps = {
passthroughPlaceholderProject: true,
};
state: State = {
fetchedProjects: [],
projectsFromStore: [],
initiallyLoaded: false,
fetching: false,
isIncomplete: null,
hasMore: null,
prevSearch: null,
nextCursor: null,
fetchError: null,
};
componentDidMount() {
const {slugs, projectIds} = this.props;
if (slugs?.length) {
this.loadSpecificProjects();
} else if (projectIds?.length) {
this.loadSpecificProjectsFromIds();
} else {
this.loadAllProjects();
}
}
componentDidUpdate(prevProps: Props) {
const {projects} = this.props;
if (projects !== prevProps.projects) {
this.updateProjectsFromStore();
}
}
/**
* Function to update projects when the store emits updates
*/
updateProjectsFromStore() {
const {allProjects, projects, slugs} = this.props;
if (allProjects) {
this.setState({fetchedProjects: projects});
return;
}
if (slugs?.length) {
// Extract the requested projects from the store based on props.slugs
const projectsMap = this.getProjectsMap(projects);
const projectsFromStore = slugs.map(slug => projectsMap.get(slug)).filter(defined);
this.setState({projectsFromStore});
}
}
/**
* List of projects that need to be fetched via API
*/
fetchQueue: Set<string> = new Set();
/**
* Memoized function that returns a `Map<project.slug, project>`
*/
getProjectsMap: (projects: Project[]) => Map<string, Project> = memoize(
projects => new Map(projects.map(project => [project.slug, project]))
);
/**
* Memoized function that returns a `Map<project.id, project>`
*/
getProjectsIdMap: (projects: Project[]) => Map<number, Project> = memoize(
projects => new Map(projects.map(project => [parseInt(project.id, 10), 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)).filter(defined);
// Add to queue
notInStore.forEach(slug => this.fetchQueue.add(slug));
this.setState({
// placeholders for projects we need to fetch
fetchedProjects: notInStore.map(slug => ({slug})),
// set initiallyLoaded if any projects were fetched from store
initiallyLoaded: !!inStore.length,
projectsFromStore,
});
if (!notInStore.length) {
return;
}
this.fetchSpecificProjects();
};
/**
* When `props.projectIds` is included, identifies if we already
* have summaries them, otherwise fetches all projects from API
*/
loadSpecificProjectsFromIds = () => {
const {projectIds, projects} = this.props;
const projectsMap = this.getProjectsIdMap(projects);
// Split projectIds into projects that are in store and not in store
// (so we can request projects not in store)
const [inStore, notInStore] = partition(projectIds, id => projectsMap.has(id));
if (notInStore.length) {
this.loadAllProjects();
return;
}
// Get the actual summaries of projects that are in store
const projectsFromStore = inStore.map(id => projectsMap.get(id)).filter(defined);
this.setState({
// set initiallyLoaded if any projects were fetched from store
initiallyLoaded: !!inStore.length,
projectsFromStore,
});
};
/**
* These will fetch projects via API (using project slug) provided by `this.fetchQueue`
*/
fetchSpecificProjects = async () => {
const {api, orgId, passthroughPlaceholderProject} = this.props;
if (!this.fetchQueue.size) {
return;
}
this.setState({
fetching: true,
});
let projects: Project[] = [];
let fetchError = null;
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: Project[] | ProjectPlaceholder[] = Array.from(
this.fetchQueue
)
.map(slug =>
projectsMap.has(slug)
? projectsMap.get(slug)
: passthroughPlaceholderProject
? {slug}
: null
)
.filter(defined);
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, allProjects} = this.props;
this.setState({
fetching: true,
});
try {
const {results, hasMore, nextCursor} = await fetchProjects(api, orgId, {
limit,
allProjects,
});
this.setState({
fetching: false,
fetchedProjects: results,
initiallyLoaded: true,
hasMore,
nextCursor,
});
} 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: string, {append}: {append?: boolean} = {}) => {
const {api, orgId, limit} = this.props;
const {prevSearch} = this.state;
const cursor = this.state.nextCursor;
this.setState({fetching: true});
try {
const {results, hasMore, nextCursor} = await fetchProjects(api, orgId, {
search,
limit,
prevSearch,
cursor,
});
this.setState((state: State) => {
let fetchedProjects;
if (append) {
// Remove duplicates
fetchedProjects = uniqBy(
[...state.fetchedProjects, ...results],
({slug}) => slug
);
} else {
fetchedProjects = results;
}
return {
fetchedProjects,
hasMore,
fetching: false,
prevSearch: search,
nextCursor,
};
});
} catch (err) {
console.error(err); // eslint-disable-line no-console
this.setState({
fetching: false,
fetchError: err,
});
}
};
render() {
const {slugs, children} = this.props;
const renderProps = {
// 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?.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,
// Reflects whether or not the initial fetch for the requested projects
// was fulfilled
initiallyLoaded: this.state.initiallyLoaded,
// The error that occurred if fetching failed
fetchError: this.state.fetchError,
};
return children(renderProps);
}
}
/**
* @deprecated consider using useProjects if possible.
*
* 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`.
*/
const Projects = withProjects(withApi(BaseProjects));
export default Projects;
type FetchProjectsOptions = {
cursor?: State['nextCursor'];
prevSearch?: State['prevSearch'];
search?: State['prevSearch'];
slugs?: string[];
} & Pick<Props, 'limit' | 'allProjects'>;
async function fetchProjects(
api: Client,
orgId: string,
{slugs, search, limit, prevSearch, cursor, allProjects}: FetchProjectsOptions = {}
) {
const query: {
collapse: string[];
all_projects?: number;
cursor?: typeof cursor;
per_page?: number;
query?: string;
} = {
// Never return latestDeploys project property from api
collapse: ['latestDeploys'],
};
if (slugs?.length) {
query.query = slugs.map(slug => `slug:${slug}`).join(' ');
}
if (search) {
query.query = `${query.query ? `${query.query} ` : ''}${search}`;
}
if (((!prevSearch && !search) || prevSearch === search) && cursor) {
query.cursor = cursor;
}
// "0" shouldn't be a valid value, so this check is fine
if (limit) {
query.per_page = limit;
}
if (allProjects) {
const projects = ProjectsStore.getState().projects;
const loading = ProjectsStore.isLoading();
// If the projects store is loaded then return all projects from the store
if (!loading) {
return {
results: projects,
hasMore: false,
};
}
// Otherwise mark the query to fetch all projects from the API
query.all_projects = 1;
}
let hasMore: null | boolean = false;
let nextCursor: null | string = null;
const [data, , resp] = await api.requestPromise(`/organizations/${orgId}/projects/`, {
includeAllArgs: true,
query,
});
const pageLinks = resp?.getResponseHeader('Link');
if (pageLinks) {
const paginationObject = parseLinkHeader(pageLinks);
hasMore =
paginationObject &&
(paginationObject.next.results || paginationObject.previous.results);
nextCursor = paginationObject.next.cursor;
}
// populate the projects store if all projects were fetched
if (allProjects) {
ProjectsStore.loadInitialData(data);
}
return {
results: data,
hasMore,
nextCursor,
};
}
interface ProjectAnalyticsData {
project_age: number;
project_has_minified_stack_trace: boolean;
project_has_replay: boolean;
project_id: number;
project_platform: string;
}
export function getAnalyicsDataForProject(
project?: Project | null
): ProjectAnalyticsData {
return {
project_has_replay: project?.hasReplays ?? false,
project_has_minified_stack_trace: project?.hasMinifiedStackTrace ?? false,
project_age: project ? getDaysSinceDate(project.dateCreated) : -1,
project_id: project ? parseInt(project.id, 10) : -1,
project_platform: project?.platform ?? '',
};
}