Skip to content
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

feat(insights): Introduce sort by selector for releases #82302

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions static/app/constants/releases.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export enum ReleasesSortOption {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this enum was just moved from

export enum ReleasesSortOption {
CRASH_FREE_USERS = 'crash_free_users',
CRASH_FREE_SESSIONS = 'crash_free_sessions',
USERS_24_HOURS = 'users_24h',
SESSIONS_24_HOURS = 'sessions_24h',
SESSIONS = 'sessions',
DATE = 'date',
BUILD = 'build',
SEMVER = 'semver',
ADOPTION = 'adoption',
}

CRASH_FREE_USERS = 'crash_free_users',
CRASH_FREE_SESSIONS = 'crash_free_sessions',
USERS_24_HOURS = 'users_24h',
SESSIONS_24_HOURS = 'sessions_24h',
SESSIONS = 'sessions',
DATE = 'date',
BUILD = 'build',
SEMVER = 'semver',
ADOPTION = 'adoption',
Comment on lines +2 to +10
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@philipphofmann can you confirm that we would like to provide all these sort by options?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would remove crash-free-users and crash-free-session, as we are filtering releases for performance data, and I don't think that is relevant. I would keep the rest.

}
129 changes: 121 additions & 8 deletions static/app/views/insights/common/components/releaseSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
import {useState} from 'react';
import {useEffect, useState} from 'react';
import styled from '@emotion/styled';
import debounce from 'lodash/debounce';

import type {SelectOption} from 'sentry/components/compactSelect';
import {CompactSelect} from 'sentry/components/compactSelect';
import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants';
import {ReleasesSortOption} from 'sentry/constants/releases';
import {IconReleases} from 'sentry/icons/iconReleases';
import {t, tn} from 'sentry/locale';
import {t, tct, tn} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import {defined} from 'sentry/utils';
import {getFormattedDate} from 'sentry/utils/dates';
import {decodeScalar} from 'sentry/utils/queryString';
import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';
import {useLocation} from 'sentry/utils/useLocation';
import {useNavigate} from 'sentry/utils/useNavigate';
import usePageFilters from 'sentry/utils/usePageFilters';
import {
ReleasesSort,
type ReleasesSortByOption,
SORT_BY_OPTIONS,
} from 'sentry/views/insights/common/components/releasesSort';
import {
useReleases,
useReleaseSelection,
Expand All @@ -24,19 +33,25 @@ export const SECONDARY_RELEASE_ALIAS = 'R2';

type Props = {
selectorKey: string;
sortBy: ReleasesSortByOption;
selectorName?: string;
selectorValue?: string;
triggerLabelPrefix?: string;
};

export function ReleaseSelector({selectorKey, selectorValue, triggerLabelPrefix}: Props) {
export function ReleaseSelector({
selectorKey,
selectorValue,
triggerLabelPrefix,
sortBy,
}: Props) {
const [searchTerm, setSearchTerm] = useState<string | undefined>(undefined);
const {data, isLoading} = useReleases(searchTerm);
const {data, isLoading} = useReleases(searchTerm, sortBy);
const {primaryRelease, secondaryRelease} = useReleaseSelection();
const navigate = useNavigate();
const location = useLocation();

const options: SelectOption<string>[] = [];
const options: (SelectOption<string> & {count?: number})[] = [];
if (defined(selectorValue)) {
const index = data?.findIndex(({version}) => version === selectorValue);
const selectedRelease = defined(index) ? data?.[index] : undefined;
Expand All @@ -49,6 +64,7 @@ export function ReleaseSelector({selectorKey, selectorValue, triggerLabelPrefix}

options.push({
value: selectorValue,
count: selectedReleaseSessionCount,
label: selectorValue,
details: (
<LabelDetails
Expand All @@ -58,12 +74,14 @@ export function ReleaseSelector({selectorKey, selectorValue, triggerLabelPrefix}
),
});
}

data
?.filter(({version}) => ![primaryRelease, secondaryRelease].includes(version))
.forEach(release => {
const option = {
value: release.version,
label: release.version,
count: release.count,
details: (
<LabelDetails screenCount={release.count} dateCreated={release.dateCreated} />
),
Expand All @@ -88,10 +106,19 @@ export function ReleaseSelector({selectorKey, selectorValue, triggerLabelPrefix}
searchable
value={selectorValue}
options={[
{
value: '_selected_release',
// We do this because the selected/default release might not be sorted,
// but instead could have been added to the top of options list.
options: options.slice(0, 1),
},
{
value: '_releases',
label: t('Sorted by date created'),
options,
label: tct('Sorted by [sortBy]', {
sortBy: SORT_BY_OPTIONS[sortBy].label,
}),
// Display other releases sorted by the selected option
options: options.slice(1),
},
]}
onSearch={debounce(val => {
Expand Down Expand Up @@ -135,8 +162,76 @@ function LabelDetails(props: LabelDetailsProps) {
);
}

function getReleasesSortBy(
sort: ReleasesSortByOption,
environments: string[]
): ReleasesSortByOption {
// Require 1 environment for date adopted
if (sort === ReleasesSortOption.ADOPTION && environments.length !== 1) {
return ReleasesSortOption.DATE;
}

if (sort in SORT_BY_OPTIONS) {
return sort;
}

// We could give a visual feedback to the user, saying that the sort by is invalid but
// since this UI will be refactored, maybe we just don't do anything now.
// This is the same fallback as the one used in static/app/views/insights/common/queries/useReleases.tsx.
return ReleasesSortOption.DATE;
}

export function ReleaseComparisonSelector() {
const {primaryRelease, secondaryRelease} = useReleaseSelection();
const location = useLocation();
const navigate = useNavigate();
const {selection} = usePageFilters();

const [localStoragedReleaseBy, setLocalStoragedReleaseBy] =
useLocalStorageState<ReleasesSortByOption>(
'insightsReleasesSortBy',
ReleasesSortOption.DATE
);

const urlStoragedReleaseBy = decodeScalar(
location.query.sortReleasesBy
) as ReleasesSortByOption;

Comment on lines +196 to +198
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would add a runtime type guard here as opposed to a cast and log sentry errors if we end up seeing invalid values. That way in case this ever breaks or some link ends up sending us bad values, we can identify and fix it

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Users have the option to modify the URL, though I don't expect many to do so. However, if they do, we have a fallback in place:

// We could give a visual feedback to the user, saying that the sort by is invalid but
// since this UI will be refactored, maybe we just don't do anything now
return ReleasesSortOption.DATE;
The casting here is just to satisfy TypeScript. I do not think we want to constantly trigger an error if users change the URL param, do we?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes they can, which is why it's good to validate :) You can always just logMessage instead of throw and error btw, because it should still be unexpected behavior

useEffect(() => {
if (urlStoragedReleaseBy === localStoragedReleaseBy) {
return;
}

// this is useful in case the user shares the url with another user
// and the user has a different sort by in their local storage
if (!urlStoragedReleaseBy) {
navigate(
{
...location,
query: {
...location.query,
sortReleasesBy: localStoragedReleaseBy,
},
},
{replace: true}
);
return;
}

setLocalStoragedReleaseBy(urlStoragedReleaseBy);
}, [
urlStoragedReleaseBy,
Comment on lines +200 to +222
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can just inline this without an effect

Suggested change
useEffect(() => {
if (urlStoragedReleaseBy === localStoragedReleaseBy) {
return;
}
// this is useful in case the user shares the url with another user
// and the user has a different sort by in their local storage
if (!urlStoragedReleaseBy) {
navigate(
{
...location,
query: {
...location.query,
sortReleasesBy: localStoragedReleaseBy,
},
},
{replace: true}
);
return;
}
setLocalStoragedReleaseBy(urlStoragedReleaseBy);
}, [
if (!urlStoragedReleaseBy && urlStorageReleaseBy !== localStorageReleaseby) {
navigate(
{
...location,
query: {
...location.query,
sortReleasesBy: localStoragedReleaseBy,
},
},
{replace: true}
);
}
const storage = urlStorageReleaseBy || localStorageReleaseBy

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmmm but isn't updating the URL a side effect? side effects should live inside a useEffect

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is an effect, but that doesn't mean it needs to be inside a useEffect :) What will happens here is that because useEffect is async, the UI might flicker with the old value for a split second, before it gets replaced as the result of the effect run, which I think is something you want to avoid

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a question about this! React documentation is very explicit that renders must be pure (though I guess in this case, it's not a huge deal). Maybe this is a good case for useLayoutEffect, since it block browser paint?

localStoragedReleaseBy,
setLocalStoragedReleaseBy,
location,
navigate,
]);

const sortReleasesBy = getReleasesSortBy(
localStoragedReleaseBy,
selection.environments
);

return (
<StyledPageSelector condensed>
<ReleaseSelector
Expand All @@ -145,13 +240,28 @@ export function ReleaseComparisonSelector() {
selectorName={t('Release 1')}
key="primaryRelease"
triggerLabelPrefix={PRIMARY_RELEASE_ALIAS}
sortBy={sortReleasesBy}
/>
<ReleaseSelector
selectorKey="secondaryRelease"
selectorName={t('Release 2')}
selectorValue={secondaryRelease}
key="secondaryRelease"
triggerLabelPrefix={SECONDARY_RELEASE_ALIAS}
sortBy={sortReleasesBy}
/>
<ReleasesSort
sortBy={sortReleasesBy}
environments={selection.environments}
onChange={value =>
navigate({
...location,
query: {
...location.query,
sortReleasesBy: value,
},
})
}
/>
</StyledPageSelector>
);
Expand All @@ -167,7 +277,10 @@ const StyledPageSelector = styled(PageFilterBar)`
& > * {
min-width: 135px;
&:last-child {
min-width: 135px;
min-width: auto;
> button[aria-haspopup] {
padding-right: ${space(1.5)};
}
}
}
`;
Expand Down
65 changes: 65 additions & 0 deletions static/app/views/insights/common/components/releasesSort.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {Button} from 'sentry/components/button';
import {CompositeSelect} from 'sentry/components/compactSelect/composite';
import {ReleasesSortOption} from 'sentry/constants/releases';
import {IconSort} from 'sentry/icons';
import {t} from 'sentry/locale';

export const SORT_BY_OPTIONS = {
[ReleasesSortOption.SESSIONS_24_HOURS]: {label: t('Active Sessions')},
[ReleasesSortOption.USERS_24_HOURS]: {label: t('Active Users')},
[ReleasesSortOption.ADOPTION]: {label: t('Adoption')},
[ReleasesSortOption.BUILD]: {label: t('Build Number')},
[ReleasesSortOption.DATE]: {label: t('Date Created')},
[ReleasesSortOption.SEMVER]: {label: t('Semantic Version')},
[ReleasesSortOption.SESSIONS]: {label: t('Total Sessions')},
};

export type ReleasesSortByOption = keyof typeof SORT_BY_OPTIONS;

interface Props {
environments: string[];
onChange: (sortBy: string) => void;
sortBy: ReleasesSortByOption;
}

export function ReleasesSort({environments, sortBy, onChange}: Props) {
return (
<CompositeSelect
trigger={triggerProps => (
<Button
{...triggerProps}
size="xs"
icon={<IconSort />}
title={t('Sort Releases')}
aria-label={t('Sort Releases')}
/>
)}
>
<CompositeSelect.Region
label={t('Sort By')}
value={sortBy}
onChange={selection => {
onChange(selection.value);
}}
options={Object.entries(SORT_BY_OPTIONS).map(([name, filter]) => {
if (name !== ReleasesSortOption.ADOPTION) {
return {
label: filter.label,
value: name,
};
}

const isNotSingleEnvironment = environments.length !== 1;
return {
label: filter.label,
value: name,
disabled: isNotSingleEnvironment,
tooltip: isNotSingleEnvironment
? t('Select one environment to use this sort option.')
: undefined,
};
})}
/>
</CompositeSelect>
);
}
15 changes: 12 additions & 3 deletions static/app/views/insights/common/queries/useReleases.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import chunk from 'lodash/chunk';

import {ReleasesSortOption} from 'sentry/constants/releases';
import type {NewQuery} from 'sentry/types/organization';
import type {Release} from 'sentry/types/release';
import type {TableData} from 'sentry/utils/discover/discoverQuery';
Expand All @@ -13,14 +14,19 @@ import useApi from 'sentry/utils/useApi';
import {useLocation} from 'sentry/utils/useLocation';
import useOrganization from 'sentry/utils/useOrganization';
import usePageFilters from 'sentry/utils/usePageFilters';
import type {ReleasesSortByOption} from 'sentry/views/insights/common/components/releasesSort';

export function useReleases(searchTerm?: string) {
export function useReleases(
searchTerm: string | undefined,
sortBy: ReleasesSortByOption | undefined
) {
const organization = useOrganization();
const location = useLocation();
const {selection, isReady} = usePageFilters();
const {environments, projects} = selection;
const api = useApi();

const activeSort = sortBy ?? ReleasesSortOption.DATE;
const releaseResults = useApiQuery<Release[]>(
[
`/organizations/${organization.slug}/releases/`,
Expand All @@ -30,7 +36,10 @@ export function useReleases(searchTerm?: string) {
per_page: 50,
environment: environments,
query: searchTerm,
sort: 'date',
sort: activeSort,
// Depending on the selected sortBy option, 'flatten' is needed or we get an error from the backend.
// A similar logic can be found in https://github.com/getsentry/sentry/blob/6209d6fbf55839bb7a2f93ef65decbf495a64974/static/app/views/releases/list/index.tsx#L106
flatten: activeSort === ReleasesSortOption.DATE ? 0 : 1,
},
},
],
Expand Down Expand Up @@ -120,7 +129,7 @@ export function useReleaseSelection(): {
} {
const location = useLocation();

const {data: releases, isLoading} = useReleases();
const {data: releases, isLoading} = useReleases(undefined, undefined);
gggritso marked this conversation as resolved.
Show resolved Hide resolved

// If there are more than 1 release, the first one should be the older one
const primaryRelease =
Expand Down
2 changes: 1 addition & 1 deletion static/app/views/releases/list/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ import {
within,
} from 'sentry-test/reactTestingLibrary';

import {ReleasesSortOption} from 'sentry/constants/releases';
import ProjectsStore from 'sentry/stores/projectsStore';
import ReleasesList from 'sentry/views/releases/list/';
import {ReleasesDisplayOption} from 'sentry/views/releases/list/releasesDisplayOptions';
import {ReleasesSortOption} from 'sentry/views/releases/list/releasesSortOptions';
import {ReleasesStatusOption} from 'sentry/views/releases/list/releasesStatusOptions';

describe('ReleasesList', () => {
Expand Down
3 changes: 2 additions & 1 deletion static/app/views/releases/list/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
import {getRelativeSummary} from 'sentry/components/timeRangeSelector/utils';
import {DEFAULT_STATS_PERIOD} from 'sentry/constants';
import {ALL_ACCESS_PROJECTS} from 'sentry/constants/pageFilters';
import {ReleasesSortOption} from 'sentry/constants/releases';
import {releaseHealth} from 'sentry/data/platformCategories';
import {IconSearch} from 'sentry/icons';
import {t} from 'sentry/locale';
Expand Down Expand Up @@ -56,7 +57,7 @@ import ReleasesAdoptionChart from './releasesAdoptionChart';
import ReleasesDisplayOptions, {ReleasesDisplayOption} from './releasesDisplayOptions';
import ReleasesPromo from './releasesPromo';
import ReleasesRequest from './releasesRequest';
import ReleasesSortOptions, {ReleasesSortOption} from './releasesSortOptions';
import ReleasesSortOptions from './releasesSortOptions';
import ReleasesStatusOptions, {ReleasesStatusOption} from './releasesStatusOptions';

type RouteParams = {
Expand Down
Loading
Loading