123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310 |
- import {Component} from 'react';
- // eslint-disable-next-line no-restricted-imports
- import {withRouter, WithRouterProps} from 'react-router';
- import {withTheme} from '@emotion/react';
- import {Query} from 'history';
- import isEqual from 'lodash/isEqual';
- import memoize from 'lodash/memoize';
- import partition from 'lodash/partition';
- import {addErrorMessage} from 'sentry/actionCreators/indicator';
- import {Client, ResponseMeta} from 'sentry/api';
- import MarkLine from 'sentry/components/charts/components/markLine';
- import {t} from 'sentry/locale';
- import {DateString, Organization} from 'sentry/types';
- import {Series} from 'sentry/types/echarts';
- import {escape} from 'sentry/utils';
- import {getFormattedDate, getUtcDateString} from 'sentry/utils/dates';
- import {formatVersion} from 'sentry/utils/formatters';
- import parseLinkHeader from 'sentry/utils/parseLinkHeader';
- import {Theme} from 'sentry/utils/theme';
- import withApi from 'sentry/utils/withApi';
- import withOrganization from 'sentry/utils/withOrganization';
- type ReleaseMetaBasic = {
- date: string;
- version: string;
- };
- type ReleaseConditions = {
- end: DateString;
- environment: Readonly<string[]>;
- project: Readonly<number[]>;
- start: DateString;
- cursor?: string;
- query?: string;
- statsPeriod?: string | null;
- };
- // This is not an exported action/function because releases list uses AsyncComponent
- // and this is not re-used anywhere else afaict
- function getOrganizationReleases(
- api: Client,
- organization: Organization,
- conditions: ReleaseConditions
- ) {
- const query = {};
- Object.keys(conditions).forEach(key => {
- let value = conditions[key];
- if (value && (key === 'start' || key === 'end')) {
- value = getUtcDateString(value);
- }
- if (value) {
- query[key] = value;
- }
- });
- api.clear();
- return api.requestPromise(`/organizations/${organization.slug}/releases/stats/`, {
- includeAllArgs: true,
- method: 'GET',
- query,
- }) as Promise<[ReleaseMetaBasic[], any, ResponseMeta]>;
- }
- type Props = WithRouterProps & {
- api: Client;
- children: (s: State) => React.ReactNode;
- end: DateString;
- environments: Readonly<string[]>;
- organization: Organization;
- projects: Readonly<number[]>;
- start: DateString;
- theme: Theme;
- emphasizeReleases?: string[];
- memoized?: boolean;
- period?: string | null;
- preserveQueryParams?: boolean;
- query?: string;
- queryExtra?: Query;
- releases?: ReleaseMetaBasic[] | null;
- tooltip?: Exclude<Parameters<typeof MarkLine>[0], undefined>['tooltip'];
- utc?: boolean | null;
- };
- type State = {
- releaseSeries: Series[];
- releases: ReleaseMetaBasic[] | null;
- };
- class ReleaseSeries extends Component<Props, State> {
- state: State = {
- releases: null,
- releaseSeries: [],
- };
- componentDidMount() {
- this._isMounted = true;
- const {releases} = this.props;
- if (releases) {
- // No need to fetch releases if passed in from props
- this.setReleasesWithSeries(releases);
- return;
- }
- this.fetchData();
- }
- componentDidUpdate(prevProps) {
- if (
- !isEqual(prevProps.projects, this.props.projects) ||
- !isEqual(prevProps.environments, this.props.environments) ||
- !isEqual(prevProps.start, this.props.start) ||
- !isEqual(prevProps.end, this.props.end) ||
- !isEqual(prevProps.period, this.props.period) ||
- !isEqual(prevProps.query, this.props.query)
- ) {
- this.fetchData();
- } else if (!isEqual(prevProps.emphasizeReleases, this.props.emphasizeReleases)) {
- this.setReleasesWithSeries(this.state.releases);
- }
- }
- componentWillUnmount() {
- this._isMounted = false;
- this.props.api.clear();
- }
- _isMounted: boolean = false;
- getOrganizationReleasesMemoized = memoize(
- (api: Client, organization: Organization, conditions: ReleaseConditions) =>
- getOrganizationReleases(api, organization, conditions),
- (_, __, conditions) =>
- Object.values(conditions)
- .map(val => JSON.stringify(val))
- .join('-')
- );
- async fetchData() {
- const {
- api,
- organization,
- projects,
- environments,
- period,
- start,
- end,
- memoized,
- query,
- } = this.props;
- const conditions: ReleaseConditions = {
- start,
- end,
- project: projects,
- environment: environments,
- statsPeriod: period,
- query,
- };
- let hasMore = true;
- const releases: ReleaseMetaBasic[] = [];
- while (hasMore) {
- try {
- const getReleases = memoized
- ? this.getOrganizationReleasesMemoized
- : getOrganizationReleases;
- const [newReleases, , resp] = await getReleases(api, organization, conditions);
- releases.push(...newReleases);
- if (this._isMounted) {
- this.setReleasesWithSeries(releases);
- }
- const pageLinks = resp?.getResponseHeader('Link');
- if (pageLinks) {
- const paginationObject = parseLinkHeader(pageLinks);
- hasMore = paginationObject?.next?.results ?? false;
- conditions.cursor = paginationObject.next.cursor;
- } else {
- hasMore = false;
- }
- } catch {
- addErrorMessage(t('Error fetching releases'));
- hasMore = false;
- }
- }
- }
- setReleasesWithSeries(releases) {
- const {emphasizeReleases = []} = this.props;
- const releaseSeries: Series[] = [];
- if (emphasizeReleases.length) {
- const [unemphasizedReleases, emphasizedReleases] = partition(
- releases,
- release => !emphasizeReleases.includes(release.version)
- );
- if (unemphasizedReleases.length) {
- releaseSeries.push(this.getReleaseSeries(unemphasizedReleases, {type: 'dotted'}));
- }
- if (emphasizedReleases.length) {
- releaseSeries.push(
- this.getReleaseSeries(emphasizedReleases, {
- opacity: 0.8,
- })
- );
- }
- } else {
- releaseSeries.push(this.getReleaseSeries(releases));
- }
- this.setState({
- releases,
- releaseSeries,
- });
- }
- getReleaseSeries = (releases, lineStyle = {}) => {
- const {
- organization,
- router,
- tooltip,
- environments,
- start,
- end,
- period,
- preserveQueryParams,
- queryExtra,
- theme,
- } = this.props;
- const query = {...queryExtra};
- if (organization.features.includes('global-views')) {
- query.project = router.location.query.project;
- }
- if (preserveQueryParams) {
- query.environment = [...environments];
- query.start = start ? getUtcDateString(start) : undefined;
- query.end = end ? getUtcDateString(end) : undefined;
- query.statsPeriod = period || undefined;
- }
- const markLine = MarkLine({
- animation: false,
- lineStyle: {
- color: theme.purple300,
- opacity: 0.3,
- type: 'solid',
- ...lineStyle,
- },
- label: {
- show: false,
- },
- data: releases.map(release => ({
- xAxis: +new Date(release.date),
- name: formatVersion(release.version, true),
- value: formatVersion(release.version, true),
- onClick: () => {
- router.push({
- pathname: `/organizations/${organization.slug}/releases/${release.version}/`,
- query,
- });
- },
- label: {
- formatter: () => formatVersion(release.version, true),
- },
- })),
- tooltip: tooltip || {
- trigger: 'item',
- formatter: ({data}: any) => {
- // XXX using this.props here as this function does not get re-run
- // unless projects are changed. Using a closure variable would result
- // in stale values.
- const time = getFormattedDate(data.value, 'MMM D, YYYY LT', {
- local: !this.props.utc,
- });
- const version = escape(formatVersion(data.name, true));
- return [
- '<div class="tooltip-series">',
- `<div><span class="tooltip-label"><strong>${t(
- 'Release'
- )}</strong></span> ${version}</div>`,
- '</div>',
- '<div class="tooltip-date">',
- time,
- '</div>',
- '</div>',
- '<div class="tooltip-arrow"></div>',
- ].join('');
- },
- },
- });
- return {
- seriesName: 'Releases',
- color: theme.purple200,
- data: [],
- markLine,
- };
- };
- render() {
- const {children} = this.props;
- return children({
- releases: this.state.releases,
- releaseSeries: this.state.releaseSeries,
- });
- }
- }
- export default withRouter(withOrganization(withApi(withTheme(ReleaseSeries))));
|