import {OrganizationFixture} from 'sentry-fixture/organization'; import {ProjectFixture} from 'sentry-fixture/project'; import {RouterFixture} from 'sentry-fixture/routerFixture'; import {TeamFixture} from 'sentry-fixture/team'; import {initializeOrg} from 'sentry-test/initializeOrg'; import { act, render, screen, userEvent, waitFor, within, } from 'sentry-test/reactTestingLibrary'; import * as projectsActions from 'sentry/actionCreators/projects'; import ProjectsStatsStore from 'sentry/stores/projectsStatsStore'; import ProjectsStore from 'sentry/stores/projectsStore'; import TeamStore from 'sentry/stores/teamStore'; import ProjectsDashboard from 'sentry/views/projectsDashboard'; jest.unmock('lodash/debounce'); jest.mock('lodash/debounce', () => { const debounceMap = new Map(); const mockDebounce = (fn: (...args: any[]) => void, timeout: number) => (...args: any[]) => { if (debounceMap.has(fn)) { clearTimeout(debounceMap.get(fn)); } debounceMap.set( fn, setTimeout(() => { fn.apply(fn, args); debounceMap.delete(fn); }, timeout) ); }; return mockDebounce; }); describe('ProjectsDashboard', function () { const org = OrganizationFixture(); const team = TeamFixture(); const teams = [team]; beforeEach(function () { TeamStore.loadInitialData(teams); MockApiClient.addMockResponse({ url: `/teams/${org.slug}/${team.slug}/members/`, body: [], }); MockApiClient.addMockResponse({ url: `/organizations/${org.slug}/projects/`, body: [], }); ProjectsStatsStore.reset(); ProjectsStore.loadInitialData([]); }); afterEach(function () { TeamStore.reset(); projectsActions._projectStatsToFetch.clear(); MockApiClient.clearMockResponses(); }); describe('empty state', function () { it('renders with 1 project, with no first event', async function () { const projects = [ProjectFixture({teams, firstEvent: null, stats: []})]; ProjectsStore.loadInitialData(projects); const teamsWithOneProject = [TeamFixture({projects})]; TeamStore.loadInitialData(teamsWithOneProject); render(); expect(await screen.findByTestId('join-team')).toBeInTheDocument(); expect(screen.getByTestId('create-project')).toBeInTheDocument(); expect( screen.getByPlaceholderText('Search for projects by name') ).toBeInTheDocument(); expect(screen.getByText('My Teams')).toBeInTheDocument(); expect(screen.getByText('Resources')).toBeInTheDocument(); expect(await screen.findByTestId('badge-display-name')).toBeInTheDocument(); expect(screen.queryByTestId('loading-placeholder')).not.toBeInTheDocument(); }); }); describe('with projects', function () { it('renders with two projects', async function () { const teamA = TeamFixture({slug: 'team1', isMember: true}); const projects = [ ProjectFixture({ id: '1', slug: 'project1', teams: [teamA], firstEvent: new Date().toISOString(), stats: [], }), ProjectFixture({ id: '2', slug: 'project2', teams: [teamA], isBookmarked: true, firstEvent: new Date().toISOString(), stats: [], }), ]; ProjectsStore.loadInitialData(projects); const teamsWithTwoProjects = [TeamFixture({projects})]; TeamStore.loadInitialData(teamsWithTwoProjects); render(); expect(await screen.findByText('My Teams')).toBeInTheDocument(); expect(screen.getAllByTestId('badge-display-name')).toHaveLength(2); expect(screen.queryByTestId('loading-placeholder')).not.toBeInTheDocument(); }); it('renders only projects for my teams by default', async function () { const teamA = TeamFixture({slug: 'team1', isMember: true, projects: undefined}); const teamProjects = [ ProjectFixture({ id: '1', slug: 'project1', teams: [teamA], firstEvent: new Date().toISOString(), stats: [], }), ]; ProjectsStore.loadInitialData([ ...teamProjects, ProjectFixture({ id: '2', slug: 'project2', teams: [], isBookmarked: true, firstEvent: new Date().toISOString(), stats: [], }), ]); const teamsWithTwoProjects = [TeamFixture({projects: teamProjects})]; TeamStore.loadInitialData(teamsWithTwoProjects); render(); expect(await screen.findByText('My Teams')).toBeInTheDocument(); expect(screen.getAllByTestId('badge-display-name')).toHaveLength(1); }); it('renders all projects if open membership is enabled and user selects all teams', async function () { const openOrg = OrganizationFixture({features: ['open-membership']}); const teamA = TeamFixture({slug: 'team1', isMember: true}); const teamB = TeamFixture({id: '2', slug: 'team2', name: 'team2', isMember: false}); const teamProjects = [ ProjectFixture({ id: '1', slug: 'project1', teams: [teamA], firstEvent: new Date().toISOString(), stats: [], }), ]; teamA.projects = teamProjects; const teamBProjects = [ ProjectFixture({ id: '2', slug: 'project2', teams: [teamB], firstEvent: new Date().toISOString(), stats: [], }), ]; teamB.projects = teamBProjects; ProjectsStore.loadInitialData([...teamProjects, ...teamBProjects]); const teamWithTwoProjects = TeamFixture({projects: teamProjects}); TeamStore.loadInitialData([teamWithTwoProjects, teamA, teamB]); const {router} = render(, { organization: openOrg, disableRouterMocks: true, initialRouterConfig: { location: { pathname: '/organizations/org-slug/projects/', }, }, }); // Open My Teams dropdown await userEvent.click(await screen.findByText('My Teams')); // Select "All Teams" by clearing the selection await userEvent.click(screen.getByRole('button', {name: 'Clear'})); // Close dropdown by clicking outside await userEvent.click(document.body); expect(await screen.findByText('All Teams')).toBeInTheDocument(); expect(screen.getAllByTestId('badge-display-name')).toHaveLength(2); await userEvent.click(screen.getByText('All Teams')); expect(await screen.findByText('Other Teams')).toBeInTheDocument(); expect(screen.getByText('#team2')).toBeInTheDocument(); expect(router.location.query).toEqual({team: ''}); }); it('renders projects for specific team that user is not a member of', async function () { const openMembershipOrg = OrganizationFixture({features: ['open-membership']}); const teamB = TeamFixture({id: '2', slug: 'team2', name: 'team2', isMember: false}); const teamA = TeamFixture({id: '1', slug: 'team1', name: 'team1', isMember: true}); const teamAProjects = [ ProjectFixture({ id: '1', slug: 'project1', teams: [teamA], firstEvent: new Date().toISOString(), stats: [], }), ]; teamA.projects = teamAProjects; const teamBProjects = [ ProjectFixture({ id: '2', slug: 'project2', name: 'project2', teams: [teamB], firstEvent: new Date().toISOString(), stats: [], isMember: false, }), ]; teamB.projects = teamBProjects; ProjectsStore.loadInitialData([...teamAProjects, ...teamBProjects]); TeamStore.loadInitialData([teamA, teamB]); const {router} = render(, { organization: openMembershipOrg, disableRouterMocks: true, initialRouterConfig: { location: { pathname: '/organizations/org-slug/projects/', }, }, }); // Open dropdown await userEvent.click(await screen.findByText('My Teams')); // Clear "My Teams" and select "team2" await userEvent.click(screen.getByRole('button', {name: 'Clear'})); await userEvent.click(screen.getByRole('option', {name: '#team2'})); // Click outside the dropdown to close it await userEvent.click(document.body); expect(await screen.findByText('#team2')).toBeInTheDocument(); expect(router.location.query).toEqual({team: '2'}); expect(screen.getByText('project2')).toBeInTheDocument(); expect(screen.getAllByTestId('badge-display-name')).toHaveLength(1); }); it('renders only projects for my teams if open membership is disabled', async function () { const {organization: closedOrg, router} = initializeOrg({ organization: {features: []}, router: { // All projects location: {query: {team: ''}}, }, }); const teamA = TeamFixture({slug: 'team1', isMember: true}); const teamProjects = [ ProjectFixture({ id: '1', slug: 'project1', teams: [teamA], firstEvent: new Date().toISOString(), stats: [], }), ]; teamA.projects = teamProjects; ProjectsStore.loadInitialData([ ...teamProjects, ProjectFixture({ id: '2', slug: 'project2', teams: [], firstEvent: new Date().toISOString(), stats: [], }), ]); const teamsWithTwoProjects = [ TeamFixture({id: '2', slug: 'team2', projects: teamProjects, isMember: false}), ]; TeamStore.loadInitialData([...teamsWithTwoProjects, teamA]); render(, { router, organization: closedOrg, }); expect(await screen.findByText('All Teams')).toBeInTheDocument(); expect(screen.getAllByTestId('badge-display-name')).toHaveLength(1); expect(screen.getByText('project1')).toBeInTheDocument(); expect(screen.queryByText('project2')).not.toBeInTheDocument(); }); it('renders correct project with selected team', async function () { const teamC = TeamFixture({ id: '1', slug: 'teamC', isMember: true, projects: [ ProjectFixture({ id: '1', slug: 'project1', stats: [], }), ProjectFixture({ id: '2', slug: 'project2', stats: [], }), ], }); const teamD = TeamFixture({ id: '2', slug: 'teamD', isMember: true, projects: [ ProjectFixture({ id: '3', slug: 'project3', }), ], }); const teamsWithSpecificProjects = [teamC, teamD]; TeamStore.loadInitialData(teamsWithSpecificProjects); const projects = [ ProjectFixture({ id: '1', slug: 'project1', teams: [teamC], firstEvent: new Date().toISOString(), stats: [], }), ProjectFixture({ id: '2', slug: 'project2', teams: [teamC], isBookmarked: true, firstEvent: new Date().toISOString(), stats: [], }), ProjectFixture({ id: '3', slug: 'project3', teams: [teamD], firstEvent: new Date().toISOString(), stats: [], }), ]; ProjectsStore.loadInitialData(projects); MockApiClient.addMockResponse({ url: `/organizations/${org.slug}/projects/`, body: projects, }); const router = RouterFixture({ location: { pathname: '', hash: '', state: '', action: 'PUSH', key: '', query: {team: '2'}, search: '?team=2`', }, }); render(, {router}); expect(await screen.findByText('project3')).toBeInTheDocument(); expect(screen.queryByText('project2')).not.toBeInTheDocument(); }); it('renders projects by search', async function () { const teamA = TeamFixture({slug: 'team1', isMember: true}); MockApiClient.addMockResponse({ url: `/organizations/${org.slug}/projects/`, body: [], }); const projects = [ ProjectFixture({ id: '1', slug: 'project1', teams: [teamA], firstEvent: new Date().toISOString(), stats: [], }), ProjectFixture({ id: '2', slug: 'project2', teams: [teamA], isBookmarked: true, firstEvent: new Date().toISOString(), stats: [], }), ]; ProjectsStore.loadInitialData(projects); const teamsWithTwoProjects = [TeamFixture({projects})]; TeamStore.loadInitialData(teamsWithTwoProjects); render(); await userEvent.type( screen.getByPlaceholderText('Search for projects by name'), 'project2{enter}' ); expect(screen.getByText('project2')).toBeInTheDocument(); await waitFor(() => { expect(screen.queryByText('project1')).not.toBeInTheDocument(); }); expect(screen.queryByTestId('loading-placeholder')).not.toBeInTheDocument(); }); it('renders bookmarked projects first in team list', async function () { const teamA = TeamFixture({slug: 'team1', isMember: true}); const projects = [ ProjectFixture({ id: '11', slug: 'm', teams: [teamA], isBookmarked: false, stats: [], }), ProjectFixture({ id: '12', slug: 'm-fave', teams: [teamA], isBookmarked: true, stats: [], }), ProjectFixture({ id: '13', slug: 'a-fave', teams: [teamA], isBookmarked: true, stats: [], }), ProjectFixture({ id: '14', slug: 'z-fave', teams: [teamA], isBookmarked: true, stats: [], }), ProjectFixture({ id: '15', slug: 'a', teams: [teamA], isBookmarked: false, stats: [], }), ProjectFixture({ id: '16', slug: 'z', teams: [teamA], isBookmarked: false, stats: [], }), ]; ProjectsStore.loadInitialData(projects); const teamsWithFavProjects = [TeamFixture({projects})]; TeamStore.loadInitialData(teamsWithFavProjects); MockApiClient.addMockResponse({ url: `/organizations/${org.slug}/projects/`, body: [ ProjectFixture({ teams, stats: [ [1517281200, 2], [1517310000, 1], ], }), ], }); render(); // check that all projects are displayed await waitFor(() => expect(screen.getAllByTestId('badge-display-name')).toHaveLength(6) ); const projectName = screen.getAllByTestId('badge-display-name'); // check that projects are in the correct order - alphabetical with bookmarked projects in front expect(within(projectName[0]!).getByText('a-fave')).toBeInTheDocument(); expect(within(projectName[1]!).getByText('m-fave')).toBeInTheDocument(); expect(within(projectName[2]!).getByText('z-fave')).toBeInTheDocument(); expect(within(projectName[3]!).getByText('a')).toBeInTheDocument(); expect(within(projectName[4]!).getByText('m')).toBeInTheDocument(); expect(within(projectName[5]!).getByText('z')).toBeInTheDocument(); }); }); describe('ProjectsStatsStore', function () { const teamA = TeamFixture({slug: 'team1', isMember: true}); const projects = [ ProjectFixture({ id: '1', slug: 'm', teams, isBookmarked: false, }), ProjectFixture({ id: '2', slug: 'm-fave', teams: [teamA], isBookmarked: true, }), ProjectFixture({ id: '3', slug: 'a-fave', teams: [teamA], isBookmarked: true, }), ProjectFixture({ id: '4', slug: 'z-fave', teams: [teamA], isBookmarked: true, }), ProjectFixture({ id: '5', slug: 'a', teams: [teamA], isBookmarked: false, }), ProjectFixture({ id: '6', slug: 'z', teams: [teamA], isBookmarked: false, }), ]; beforeEach(function () { const teamsWithStatTestProjects = [TeamFixture({projects})]; TeamStore.loadInitialData(teamsWithStatTestProjects); }); it('uses ProjectsStatsStore to load stats', async function () { ProjectsStore.loadInitialData(projects); jest.useFakeTimers(); ProjectsStatsStore.onStatsLoadSuccess([ {...projects[0]!, stats: [[1517281200, 2]]}, ]); const loadStatsSpy = jest.spyOn(projectsActions, 'loadStatsForProject'); const mock = MockApiClient.addMockResponse({ url: `/organizations/${org.slug}/projects/`, body: projects.map(project => ({ ...project, stats: [ [1517281200, 2], [1517310000, 1], ], })), }); const {unmount} = render(); expect(loadStatsSpy).toHaveBeenCalledTimes(6); expect(mock).not.toHaveBeenCalled(); const projectSummary = screen.getAllByTestId('summary-links'); // Has 5 Loading Cards because 1 project has been loaded in store already expect( within(projectSummary[0]!).getByTestId('loading-placeholder') ).toBeInTheDocument(); expect( within(projectSummary[1]!).getByTestId('loading-placeholder') ).toBeInTheDocument(); expect( within(projectSummary[2]!).getByTestId('loading-placeholder') ).toBeInTheDocument(); expect( within(projectSummary[3]!).getByTestId('loading-placeholder') ).toBeInTheDocument(); expect(within(projectSummary[4]!).getByText('Errors: 2')).toBeInTheDocument(); expect( within(projectSummary[5]!).getByTestId('loading-placeholder') ).toBeInTheDocument(); // Advance timers so that batched request fires act(() => jest.advanceTimersByTime(51)); expect(mock).toHaveBeenCalledTimes(1); // query ids = 3, 2, 4 = bookmarked // 1 - already loaded in store so shouldn't be in query expect(mock).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ query: expect.objectContaining({ query: 'id:3 id:2 id:4 id:5 id:6', }), }) ); jest.useRealTimers(); // All cards have loaded await waitFor(() => { expect(within(projectSummary[0]!).getByText('Errors: 3')).toBeInTheDocument(); }); expect(within(projectSummary[1]!).getByText('Errors: 3')).toBeInTheDocument(); expect(within(projectSummary[2]!).getByText('Errors: 3')).toBeInTheDocument(); expect(within(projectSummary[3]!).getByText('Errors: 3')).toBeInTheDocument(); expect(within(projectSummary[4]!).getByText('Errors: 3')).toBeInTheDocument(); expect(within(projectSummary[5]!).getByText('Errors: 3')).toBeInTheDocument(); // Resets store when it unmounts unmount(); expect(ProjectsStatsStore.getAll()).toEqual({}); }); }); });