import {Fragment, useEffect, useMemo, useState} from 'react'; import LazyLoad, {forceCheck} from 'react-lazyload'; import styled from '@emotion/styled'; import {withProfiler} from '@sentry/react'; import debounce from 'lodash/debounce'; import uniqBy from 'lodash/uniqBy'; import type {Client} from 'sentry/api'; import {LinkButton} from 'sentry/components/button'; import ButtonBar from 'sentry/components/buttonBar'; import * as Layout from 'sentry/components/layouts/thirds'; import LoadingError from 'sentry/components/loadingError'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import NoProjectMessage from 'sentry/components/noProjectMessage'; import {PageHeadingQuestionTooltip} from 'sentry/components/pageHeadingQuestionTooltip'; import {canCreateProject} from 'sentry/components/projects/canCreateProject'; import SearchBar from 'sentry/components/searchBar'; import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle'; import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants'; import {IconAdd, IconUser} from 'sentry/icons'; import {t} from 'sentry/locale'; import ProjectsStatsStore from 'sentry/stores/projectsStatsStore'; import {space} from 'sentry/styles/space'; import type {RouteComponentProps} from 'sentry/types/legacyReactRouter'; import type {Organization} from 'sentry/types/organization'; import type {Project, TeamWithProjects} from 'sentry/types/project'; import { onRenderCallback, Profiler, setGroupedEntityTag, } from 'sentry/utils/performanceForSentry'; import {sortProjects} from 'sentry/utils/project/sortProjects'; import useOrganization from 'sentry/utils/useOrganization'; import useProjects from 'sentry/utils/useProjects'; import {useUser} from 'sentry/utils/useUser'; import withApi from 'sentry/utils/withApi'; import withOrganization from 'sentry/utils/withOrganization'; import withTeamsForUser from 'sentry/utils/withTeamsForUser'; import TeamFilter from 'sentry/views/alerts/list/rules/teamFilter'; import ProjectCard from './projectCard'; import Resources from './resources'; import {getTeamParams} from './utils'; type Props = { api: Client; error: Error | null; loadingTeams: boolean; organization: Organization; teams: TeamWithProjects[]; } & RouteComponentProps<{}, {}>; function ProjectCardList({projects}: {projects: Project[]}) { const organization = useOrganization(); const hasProjectAccess = organization.access.includes('project:read'); // By default react-lazyload will only check for intesecting components on scroll // This forceCheck call is necessary to recalculate when filtering projects useEffect(() => { forceCheck(); }, [projects]); return ( {sortProjects(projects).map(project => ( ))} ); } function Dashboard({teams, organization, loadingTeams, error, router, location}: Props) { useEffect(() => { return function cleanup() { ProjectsStatsStore.reset(); }; }, []); const user = useUser(); const [projectQuery, setProjectQuery] = useState(''); const debouncedSearchQuery = useMemo( () => debounce(handleSearch, DEFAULT_DEBOUNCE_DURATION), [] ); const {projects, fetching, fetchError} = useProjects(); const showNonMemberProjects = useMemo(() => { const isOrgAdminOrManager = organization.orgRole === 'owner' || organization.orgRole === 'manager'; const isOpenMembership = organization.features.includes('open-membership'); return user.isSuperuser || isOrgAdminOrManager || isOpenMembership; }, [user, organization.orgRole, organization.features]); const canUserCreateProject = canCreateProject(organization); if (loadingTeams || fetching) { return ; } if (error || fetchError) { return ; } const canJoinTeam = organization.access.includes('team:read'); const selectedTeams = getTeamParams(location.query.team ?? 'myteams'); const filteredTeams = selectedTeams[0] === 'myteams' || selectedTeams.length === 0 ? teams : teams.filter(team => selectedTeams.includes(team.id)); const filteredTeamProjects = uniqBy( (filteredTeams ?? teams).flatMap(team => team.projects), 'id' ); setGroupedEntityTag('projects.total', 1000, projects.length); const currentProjects = // No teams are specifically selected and query parameter is present // Use all projects if open membership is enabled location.query.team === '' && showNonMemberProjects ? projects : // No teams are specifically selected - Use "myteams" filteredTeamProjects; const filteredProjects = (currentProjects ?? projects).filter(project => project.slug.includes(projectQuery) ); const showResources = projects.length === 1 && !projects[0]!.firstEvent; function handleSearch(searchQuery: string) { setProjectQuery(searchQuery); } function handleChangeFilter(activeFilters: string[]) { router.push({ pathname: location.pathname, query: { ...location.query, team: activeFilters.length > 0 ? activeFilters : '', }, }); } return ( {t('Projects')} } title={ canJoinTeam ? undefined : t('You do not have permission to join a team.') } disabled={!canJoinTeam} to={`/settings/${organization.slug}/teams/`} data-test-id="join-team" > {t('Join a Team')} } data-test-id="create-project" > {t('Create Project')} {showResources && } ); } function OrganizationDashboard(props: Props) { return ( ); } const SearchAndSelectorWrapper = styled('div')` display: flex; gap: ${space(2)}; justify-content: flex-end; align-items: flex-end; margin-bottom: ${space(2)}; @media (max-width: ${p => p.theme.breakpoints.small}) { display: block; } @media (min-width: ${p => p.theme.breakpoints.xlarge}) { display: flex; } `; const StyledSearchBar = styled(SearchBar)` flex-grow: 1; @media (max-width: ${p => p.theme.breakpoints.small}) { margin-top: ${space(1)}; } `; const ProjectCards = styled('div')` display: grid; gap: ${space(3)}; grid-template-columns: repeat(auto-fill, minmax(1fr, 400px)); @media (min-width: ${p => p.theme.breakpoints.small}) { grid-template-columns: repeat(auto-fill, minmax(470px, 1fr)); } @media (min-width: ${p => p.theme.breakpoints.medium}) { grid-template-columns: repeat(auto-fill, minmax(450px, 1fr)); } `; export {Dashboard}; export default withApi( withOrganization(withTeamsForUser(withProfiler(OrganizationDashboard))) );