From e324e6cea717fbca7f57aa6fff35a8dde40614b3 Mon Sep 17 00:00:00 2001 From: Adrian Date: Wed, 16 Apr 2025 14:14:30 -0700 Subject: [PATCH 1/3] feat: add updateRepository function to update local storage and url query params for repo selection --- .../app/actionCreators/pageFilters.spec.tsx | 9 +++++ static/app/actionCreators/pageFilters.tsx | 36 ++++++++++++++++--- .../pageFilters/container.spec.tsx | 31 ++++++++++++---- .../organizations/pageFilters/parse.tsx | 19 ++++++++++ .../organizations/pageFilters/persistence.tsx | 10 +++++- .../organizations/pageFilters/types.tsx | 1 + .../organizations/pageFilters/utils.tsx | 1 + static/app/constants/pageFilters.tsx | 1 + static/app/stores/pageFiltersStore.spec.tsx | 22 ++++++++++++ static/app/stores/pageFiltersStore.tsx | 16 +++++++++ static/app/types/core.tsx | 6 +++- 11 files changed, 138 insertions(+), 14 deletions(-) diff --git a/static/app/actionCreators/pageFilters.spec.tsx b/static/app/actionCreators/pageFilters.spec.tsx index 1c587d4c71e3dc..d72650a30e6c50 100644 --- a/static/app/actionCreators/pageFilters.spec.tsx +++ b/static/app/actionCreators/pageFilters.spec.tsx @@ -124,6 +124,7 @@ describe('PageFilters ActionCreators', function () { expect.objectContaining({ environments: [], projects: [], + repository: null, }), new Set(['projects']), false @@ -275,6 +276,7 @@ describe('PageFilters ActionCreators', function () { organization, queryParams: { project: '1', + repository: 'repo-from-query', }, memberProjects: projects, nonMemberProjects: [], @@ -292,6 +294,7 @@ describe('PageFilters ActionCreators', function () { }, projects: [1], environments: [], + repository: 'repo-from-query', }, new Set(), true @@ -301,6 +304,7 @@ describe('PageFilters ActionCreators', function () { query: { environment: [], project: ['1'], + repository: 'repo-from-query', }, }) ); @@ -327,6 +331,7 @@ describe('PageFilters ActionCreators', function () { }, projects: [-1], environments: [], + repository: null, }, new Set(), true @@ -355,6 +360,7 @@ describe('PageFilters ActionCreators', function () { }, projects: [1], environments: [], + repository: null, }, new Set(), true @@ -373,6 +379,7 @@ describe('PageFilters ActionCreators', function () { end: null, period: '14d', utc: null, + repository: null, }, pinnedFilters: new Set(), }); @@ -405,6 +412,7 @@ describe('PageFilters ActionCreators', function () { end: null, period: '7d', utc: null, + repository: null, }, pinnedFilters: new Set(['environments', 'datetime', 'projects']), }); @@ -696,6 +704,7 @@ describe('PageFilters ActionCreators', function () { end: null, period: '14d', utc: null, + repository: null, }, pinnedFilters: new Set(['projects', 'environments', 'datetime']), }); diff --git a/static/app/actionCreators/pageFilters.tsx b/static/app/actionCreators/pageFilters.tsx index f3cc90fdb23e09..638553dd65590b 100644 --- a/static/app/actionCreators/pageFilters.tsx +++ b/static/app/actionCreators/pageFilters.tsx @@ -66,6 +66,7 @@ type PageFiltersUpdate = { environment?: string[] | null; period?: string | null; project?: number[] | null; + repository?: string | null; start?: DateString; utc?: boolean | null; }; @@ -206,6 +207,8 @@ export function initializeUrlState({ const hasDatetimeInUrl = Object.keys(pick(queryParams, DATE_TIME_KEYS)).length > 0; const hasProjectOrEnvironmentInUrl = Object.keys(pick(queryParams, [URL_PARAM.PROJECT, URL_PARAM.ENVIRONMENT])).length > 0; + const hasRepositoryInUrl = + Object.keys(pick(queryParams, URL_PARAM.REPOSITORY)).length > 0; // We should only check and update the desync state if the site has just been loaded // (not counting route changes). To check this, we can use the `isReady` state: if it's @@ -254,6 +257,12 @@ export function initializeUrlState({ } } + // We want to update the pageFilter's object with a repository if it is in the URL + // or in local storage, in that order. + if (hasRepositoryInUrl) { + pageFilters.repository = parsed.repository ?? null; + } + const storedPageFilters = skipLoadLastUsed ? null : getPageFilterStorage(orgSlug, storageNamespace); @@ -293,9 +302,13 @@ export function initializeUrlState({ pageFilters.datetime = getDatetimeFromState(storedState); shouldUsePinnedDatetime = true; } + + if (!hasRepositoryInUrl && pinnedFilters.has('repository')) { + pageFilters.repository = storedState.repository ?? null; + } } - const {projects, environments: environment, datetime} = pageFilters; + const {projects, environments: environment, datetime, repository} = pageFilters; let newProject: number[] | null = null; let project = projects; @@ -358,9 +371,8 @@ export function initializeUrlState({ } } } - const pinnedFilters = organization.features.includes('new-page-filter') - ? new Set(['projects', 'environments', 'datetime']) + ? new Set(['projects', 'environments', 'datetime', 'repository']) : (storedPageFilters?.pinnedFilters ?? new Set()); PageFiltersStore.onInitializeUrlState(pageFilters, pinnedFilters, shouldPersist); @@ -392,7 +404,7 @@ export function initializeUrlState({ }; if (!skipInitializeUrlParams) { - updateParams({project, environment, ...newDatetime}, router, { + updateParams({project, environment, ...newDatetime, repository}, router, { replace: true, keepCursor: true, }); @@ -469,6 +481,19 @@ export function updateDateTime( persistPageFilters('datetime', options); } +/** + * Updates store and global repository selection URL param if `router` is supplied + * + * @param {Object} repository Object with repository key + * @param {Object} [router] Router object + * @param {Object} [options] Options object + */ +export function updateRepository(repository: string, router?: Router, options?: Options) { + PageFiltersStore.updateRepository(repository); + updateParams({repository}, router, options); + persistPageFilters('repository', options); +} + /** * Pins a particular filter so that it is read out of local storage */ @@ -669,7 +694,7 @@ function getNewQueryParams( const extraParams = omit(cleanCurrentQuery, omittedParameters); // Override parameters - const {project, environment, start, end, utc} = { + const {project, environment, start, end, utc, repository} = { ...currentQueryState, ...obj, }; @@ -684,6 +709,7 @@ function getNewQueryParams( end: statsPeriod ? null : end instanceof Date ? getUtcDateString(end) : end, utc: utc ? 'true' : null, statsPeriod, + repository, ...extraParams, }; diff --git a/static/app/components/organizations/pageFilters/container.spec.tsx b/static/app/components/organizations/pageFilters/container.spec.tsx index de897c6b1f3780..3b59dad829652d 100644 --- a/static/app/components/organizations/pageFilters/container.spec.tsx +++ b/static/app/components/organizations/pageFilters/container.spec.tsx @@ -112,6 +112,7 @@ describe('PageFiltersContainer', function () { }, environments: [], projects: [], + repository: null, }) ); }); @@ -140,6 +141,7 @@ describe('PageFiltersContainer', function () { }, environments: ['prod'], projects: [], + repository: null, }, }) ); @@ -172,6 +174,7 @@ describe('PageFiltersContainer', function () { }, environments: [], projects: [], + repository: null, }, }) ); @@ -202,6 +205,7 @@ describe('PageFiltersContainer', function () { }, environments: [], projects: [], + repository: null, }, }) ); @@ -243,6 +247,7 @@ describe('PageFiltersContainer', function () { }, environments: [], projects: [], + repository: null, }, }); }); @@ -287,11 +292,13 @@ describe('PageFiltersContainer', function () { }); it('does not load from local storage when there are URL params', function () { - jest - .spyOn(localStorage, 'getItem') - .mockImplementation(() => - JSON.stringify({projects: [3], environments: ['staging']}) - ); + jest.spyOn(localStorage, 'getItem').mockImplementation(() => + JSON.stringify({ + projects: [3], + environments: ['staging'], + repository: 'repo-from-store', + }) + ); const initializationObj = initializeOrg({ organization: { @@ -301,7 +308,10 @@ describe('PageFiltersContainer', function () { // we need this to be set to make sure org in context is same as // current org in URL params: {orgId: 'org-slug'}, - location: {pathname: '/test', query: {project: ['1', '2']}}, + location: { + pathname: '/test', + query: {project: ['1', '2'], repository: 'repo-from-url'}, + }, }, }); @@ -312,6 +322,7 @@ describe('PageFiltersContainer', function () { ); expect(PageFiltersStore.getState().selection.projects).toEqual([1, 2]); + expect(PageFiltersStore.getState().selection.repository).toBe('repo-from-url'); // Since these are coming from URL, there should be no changes and // router does not need to be called @@ -327,7 +338,10 @@ describe('PageFiltersContainer', function () { // we need this to be set to make sure org in context is same as // current org in URL params: {orgId: 'org-slug'}, - location: {pathname: '/test', query: {project: ['1', '2']}}, + location: { + pathname: '/test', + query: {project: ['1', '2'], repository: 'repo-from-store'}, + }, }, }); @@ -338,6 +352,7 @@ describe('PageFiltersContainer', function () { ); expect(PageFiltersStore.getState().selection.projects).toEqual([1, 2]); + expect(PageFiltersStore.getState().selection.repository).toBe('repo-from-store'); // Since these are coming from URL, there should be no changes and // router does not need to be called @@ -672,6 +687,7 @@ describe('PageFiltersContainer', function () { }, environments: [], projects: [], + repository: null, }) ); @@ -697,6 +713,7 @@ describe('PageFiltersContainer', function () { }, environments: [], projects: [], + repository: null, }) ); diff --git a/static/app/components/organizations/pageFilters/parse.tsx b/static/app/components/organizations/pageFilters/parse.tsx index 9b957c897b6125..f4bea03c783ca1 100644 --- a/static/app/components/organizations/pageFilters/parse.tsx +++ b/static/app/components/organizations/pageFilters/parse.tsx @@ -150,6 +150,22 @@ function getEnvironment(maybe: ParamValue) { return toArray(maybe); } +/** + * Normalizes a string into the repository parameter. Repository is singular and + * doesn't handle arrays + */ +function getRepository(maybe: ParamValue) { + if (!defined(maybe)) { + return undefined; + } + + if (Array.isArray(maybe)) { + return null; + } + + return maybe; +} + type InputParams = { [others: string]: any; end?: ParamValue | Date; @@ -280,6 +296,7 @@ export function normalizeDateTimeParams( * * - Normalizes `project` and `environment` into a consistent list object. * - Normalizes date time filter parameters (using normalizeDateTimeParams). + * - Gets repository string from query params * - Parses `start` and `end` into Date objects. */ export function getStateFromQuery( @@ -290,6 +307,7 @@ export function getStateFromQuery( const project = getProject(query[URL_PARAM.PROJECT]) ?? null; const environment = getEnvironment(query[URL_PARAM.ENVIRONMENT]) ?? null; + const repository = getRepository(query[URL_PARAM.REPOSITORY]) ?? null; const dateTimeParams = normalizeDateTimeParams(query, normalizeOptions); @@ -308,6 +326,7 @@ export function getStateFromQuery( start: start || null, end: end || null, utc: typeof utc === 'undefined' ? null : utc === 'true', + repository, }; return state; diff --git a/static/app/components/organizations/pageFilters/persistence.tsx b/static/app/components/organizations/pageFilters/persistence.tsx index 454d2c5413935f..b448ccdc3500e9 100644 --- a/static/app/components/organizations/pageFilters/persistence.tsx +++ b/static/app/components/organizations/pageFilters/persistence.tsx @@ -19,6 +19,7 @@ type StoredObject = { projects: number[]; start: string | null; utc: 'true' | null; + repository?: string | null; }; /** @@ -51,6 +52,10 @@ export function setPageFiltersStorage( ? selection.environments : (currentStoredState?.environment ?? []); + const repository = updateFilters.has('repository') + ? selection.repository + : (currentStoredState?.repository ?? null); + const shouldUpdateDatetime = updateFilters.has('datetime'); const currentStart = shouldUpdateDatetime @@ -85,6 +90,7 @@ export function setPageFiltersStorage( period, utc, pinnedFilters: Array.from(pinnedFilters), + repository, }; const localStorageKey = makeLocalStorageKey( @@ -124,7 +130,8 @@ export function getPageFilterStorage(orgSlug: string, storageNamespace = '') { return null; } - const {projects, environments, start, end, period, utc, pinnedFilters} = decoded; + const {projects, environments, start, end, period, utc, pinnedFilters, repository} = + decoded; const state = getStateFromQuery( { @@ -134,6 +141,7 @@ export function getPageFilterStorage(orgSlug: string, storageNamespace = '') { end, period, utc, + repository, }, {allowAbsoluteDatetime: true} ); diff --git a/static/app/components/organizations/pageFilters/types.tsx b/static/app/components/organizations/pageFilters/types.tsx index 775bfd0af87ed2..804f54f916089c 100644 --- a/static/app/components/organizations/pageFilters/types.tsx +++ b/static/app/components/organizations/pageFilters/types.tsx @@ -21,6 +21,7 @@ export type PageFiltersState = { environment: string[] | null; period: string | null; project: number[] | null; + repository: string | null; start: Date | null; utc: boolean | null; }; diff --git a/static/app/components/organizations/pageFilters/utils.tsx b/static/app/components/organizations/pageFilters/utils.tsx index 98f17cd9138eec..b17c383d0d2912 100644 --- a/static/app/components/organizations/pageFilters/utils.tsx +++ b/static/app/components/organizations/pageFilters/utils.tsx @@ -23,6 +23,7 @@ export function getDefaultSelection(): PageFilters { projects: [], environments: [], datetime, + repository: null, }; } diff --git a/static/app/constants/pageFilters.tsx b/static/app/constants/pageFilters.tsx index c0ba13d12ed0a6..adfa8ec648065b 100644 --- a/static/app/constants/pageFilters.tsx +++ b/static/app/constants/pageFilters.tsx @@ -5,6 +5,7 @@ export const URL_PARAM = { PERIOD: 'statsPeriod', PROJECT: 'project', ENVIRONMENT: 'environment', + REPOSITORY: 'repository', }; export const PAGE_URL_PARAM = { diff --git a/static/app/stores/pageFiltersStore.spec.tsx b/static/app/stores/pageFiltersStore.spec.tsx index 63826e2b70b441..886576f8bbf1c2 100644 --- a/static/app/stores/pageFiltersStore.spec.tsx +++ b/static/app/stores/pageFiltersStore.spec.tsx @@ -6,6 +6,7 @@ import { updateEnvironments, updatePersistence, updateProjects, + updateRepository, } from 'sentry/actionCreators/pageFilters'; import PageFiltersStore from 'sentry/stores/pageFiltersStore'; @@ -32,6 +33,7 @@ describe('PageFiltersStore', function () { projects: [], environments: [], datetime: {period: '14d', start: null, end: null, utc: null}, + repository: null, }, }); }); @@ -59,6 +61,26 @@ describe('PageFiltersStore', function () { expect(triggerSpy).toHaveBeenCalledTimes(1); }); + it('returns updated repository when calling updateRepository()', async function () { + expect(PageFiltersStore.getState().selection.repository).toBeNull(); + updateRepository('test-repo'); + await tick(); + expect(PageFiltersStore.getState().selection.repository).toBe('test-repo'); + }); + + it('does not update repository when calling updateRepository() if same value', async function () { + const triggerSpy = jest.spyOn(PageFiltersStore, 'trigger'); + const testRepoName = 'repo 123'; + PageFiltersStore.updateRepository(testRepoName); + + await waitFor( + () => PageFiltersStore.getState().selection.repository === testRepoName + ); + PageFiltersStore.updateRepository(testRepoName); + await tick(); + expect(triggerSpy).toHaveBeenCalledTimes(1); + }); + it('updateDateTime()', async function () { expect(PageFiltersStore.getState().selection.datetime).toEqual({ period: '14d', diff --git a/static/app/stores/pageFiltersStore.tsx b/static/app/stores/pageFiltersStore.tsx index 81a9555366ae0a..57ca912803af1a 100644 --- a/static/app/stores/pageFiltersStore.tsx +++ b/static/app/stores/pageFiltersStore.tsx @@ -80,6 +80,7 @@ interface PageFiltersStoreDefinition extends StrictStoreDefinition; /** * Represents a pinned page filter sentinel value */ -export type PinnedPageFilter = 'projects' | 'environments' | 'datetime'; +export type PinnedPageFilter = 'projects' | 'environments' | 'datetime' | 'repository'; export type PageFilters = { /** @@ -163,6 +163,10 @@ export type PageFilters = { * Currently selected Project IDs */ projects: number[]; + /** + * Currently selected repository + */ + repository?: string | null; }; type InitialState = {type: 'initial'}; From 3aabb4eeba1e27d195f4159338d382677360b2d6 Mon Sep 17 00:00:00 2001 From: Adrian Date: Wed, 16 Apr 2025 14:39:04 -0700 Subject: [PATCH 2/3] adjust repository type to also be optional --- static/app/components/organizations/pageFilters/types.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/components/organizations/pageFilters/types.tsx b/static/app/components/organizations/pageFilters/types.tsx index 804f54f916089c..426d1aa3921753 100644 --- a/static/app/components/organizations/pageFilters/types.tsx +++ b/static/app/components/organizations/pageFilters/types.tsx @@ -21,7 +21,7 @@ export type PageFiltersState = { environment: string[] | null; period: string | null; project: number[] | null; - repository: string | null; start: Date | null; utc: boolean | null; + repository?: string | null; }; From efed0d2c48277bdb98db9e1651f792dacb36457e Mon Sep 17 00:00:00 2001 From: Adrian Date: Thu, 17 Apr 2025 14:07:02 -0700 Subject: [PATCH 3/3] Add base repo picker component --- .../codecov/repoPicker/repoPicker.tsx | 34 ++++++ .../codecov/repoPicker/repoSelector.tsx | 114 ++++++++++++++++++ static/app/components/codecov/utils.tsx | 26 ++++ static/app/views/codecov/tests/tests.tsx | 2 + 4 files changed, 176 insertions(+) create mode 100644 static/app/components/codecov/repoPicker/repoPicker.tsx create mode 100644 static/app/components/codecov/repoPicker/repoSelector.tsx diff --git a/static/app/components/codecov/repoPicker/repoPicker.tsx b/static/app/components/codecov/repoPicker/repoPicker.tsx new file mode 100644 index 00000000000000..b7c0a0a7d67b2b --- /dev/null +++ b/static/app/components/codecov/repoPicker/repoPicker.tsx @@ -0,0 +1,34 @@ +import {updateRepository} from 'sentry/actionCreators/pageFilters'; +import type {RepoSelectorProps} from 'sentry/components/codecov/repoPicker/repoSelector'; +import {RepoSelector} from 'sentry/components/codecov/repoPicker/repoSelector'; +import usePageFilters from 'sentry/utils/usePageFilters'; +import useRouter from 'sentry/utils/useRouter'; + +export interface RepoPickerProps + extends Partial< + Partial> + > {} + +export function RepoPicker({ + menuWidth, + triggerProps = {}, + ...selectProps +}: RepoPickerProps) { + const router = useRouter(); + const {selection} = usePageFilters(); + const repository = selection?.repository ?? null; + + return ( + { + updateRepository(newRepository, router, { + save: true, + }); + }} + menuWidth={menuWidth ? '22em' : undefined} + triggerProps={triggerProps} + /> + ); +} diff --git a/static/app/components/codecov/repoPicker/repoSelector.tsx b/static/app/components/codecov/repoPicker/repoSelector.tsx new file mode 100644 index 00000000000000..387d37d73c34e3 --- /dev/null +++ b/static/app/components/codecov/repoPicker/repoSelector.tsx @@ -0,0 +1,114 @@ +import {useCallback, useMemo} from 'react'; +import styled from '@emotion/styled'; + +import { + mapIndividualRepository, + mapRepositoryList, +} from 'sentry/components/codecov/utils'; +import type {SelectOption, SingleSelectProps} from 'sentry/components/core/compactSelect'; +import {CompactSelect} from 'sentry/components/core/compactSelect'; +import DropdownButton from 'sentry/components/dropdownButton'; +import {t} from 'sentry/locale'; + +const CODECOV_PLACEHOLDER_REPOS = ['test repo 1', 'test-repo-2', 'Test Repo 3']; + +export interface RepoSelectorProps + extends Omit< + SingleSelectProps, + 'multiple' | 'hideOptions' | 'onChange' | 'onClose' | 'options' | 'value' + > { + /** + * Relative date value + */ + repository: string | null; + /** + * Custom width value for compact select + */ + menuWidth?: string; + onChange?: (data: string) => void; +} + +export function RepoSelector({ + menuWidth, + onChange, + trigger, + repository, + ...selectProps +}: RepoSelectorProps) { + const options = useMemo((): Array> => { + const selectedRepository = mapIndividualRepository(repository); + // TODO: When API is ready, fetch the options from API + const repositoryList = mapRepositoryList(CODECOV_PLACEHOLDER_REPOS); + + const repositoriesMap = { + ...selectedRepository, + ...repositoryList, + }; + + // TODO: ensure list is sorted when API is implemented + const repositoriesList = Object.entries(repositoriesMap); + + return repositoriesList.map(([value, label]): SelectOption => { + return { + value, + label: {label}, + textValue: typeof label === 'string' ? label : value, + }; + }); + }, [repository]); + + const handleChange = useCallback['onChange']>>( + newSelected => { + onChange?.(newSelected.value); + }, + [onChange] + ); + + return ( + { + const defaultLabel = options.some(item => item.value === repository) + ? repository?.toUpperCase() + : t('Select Repo'); + + return ( + + + {selectProps.triggerLabel ?? defaultLabel} + + + ); + }) + } + /> + ); +} + +const TriggerLabelWrap = styled('span')` + position: relative; + min-width: 0; +`; + +const TriggerLabel = styled('span')` + ${p => p.theme.overflowEllipsis} + width: auto; +`; + +const OptionLabel = styled('span')` + div { + margin: 0; + } +`; diff --git a/static/app/components/codecov/utils.tsx b/static/app/components/codecov/utils.tsx index 9c82455c27c47d..4576989c95d6e0 100644 --- a/static/app/components/codecov/utils.tsx +++ b/static/app/components/codecov/utils.tsx @@ -19,3 +19,29 @@ export function isValidCodecovRelativePeriod(period: string | null): boolean { } // Date Picker Utils End + +// Repo Picker Utils Start +/** + * Creates a mapping of 'A:A' for the repository if it is not null + */ +export function mapIndividualRepository( + repository: string | null +): Record { + if (repository === null) { + return {}; + } + + return {[repository]: repository}; +} + +/** + * Creates a mapping of 'A:A' for every repository in the repository list if it is not null + */ +export function mapRepositoryList(repositories: string[] | null): Record { + if (!repositories || (repositories && repositories.length === 0)) { + return {}; + } + + return Object.fromEntries(repositories.map(repository => [repository, repository])); +} +// Repo Picker Utils End diff --git a/static/app/views/codecov/tests/tests.tsx b/static/app/views/codecov/tests/tests.tsx index e104058370f27c..2a179a5195400c 100644 --- a/static/app/views/codecov/tests/tests.tsx +++ b/static/app/views/codecov/tests/tests.tsx @@ -1,6 +1,7 @@ import styled from '@emotion/styled'; import {DatePicker} from 'sentry/components/codecov/datePicker/datePicker'; +import {RepoPicker} from 'sentry/components/codecov/repoPicker/repoPicker'; import PageFilterBar from 'sentry/components/organizations/pageFilterBar'; import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container'; import {space} from 'sentry/styles/space'; @@ -20,6 +21,7 @@ export default function TestsPage() { defaultSelection={{datetime: DEFAULT_CODECOV_DATETIME_SELECTION}} > +