|
@@ -1,7 +1,6 @@
|
|
|
import {Fragment, useCallback, useEffect, useState} from 'react';
|
|
|
import LazyLoad from 'react-lazyload';
|
|
|
import {RouteComponentProps} from 'react-router';
|
|
|
-import {useTheme} from '@emotion/react';
|
|
|
import styled from '@emotion/styled';
|
|
|
import {withProfiler} from '@sentry/react';
|
|
|
import debounce from 'lodash/debounce';
|
|
@@ -10,8 +9,8 @@ import uniqBy from 'lodash/uniqBy';
|
|
|
|
|
|
import {Client} from 'sentry/api';
|
|
|
import Button from 'sentry/components/button';
|
|
|
-import TeamSelector from 'sentry/components/forms/teamSelector';
|
|
|
import IdBadge from 'sentry/components/idBadge';
|
|
|
+import * as Layout from 'sentry/components/layouts/thirds';
|
|
|
import Link from 'sentry/components/links/link';
|
|
|
import LoadingError from 'sentry/components/loadingError';
|
|
|
import LoadingIndicator from 'sentry/components/loadingIndicator';
|
|
@@ -26,14 +25,15 @@ import ProjectsStatsStore from 'sentry/stores/projectsStatsStore';
|
|
|
import space from 'sentry/styles/space';
|
|
|
import {Organization, TeamWithProjects} from 'sentry/types';
|
|
|
import {sortProjects} from 'sentry/utils';
|
|
|
-import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser';
|
|
|
import withApi from 'sentry/utils/withApi';
|
|
|
import withOrganization from 'sentry/utils/withOrganization';
|
|
|
import withTeamsForUser from 'sentry/utils/withTeamsForUser';
|
|
|
+import TeamFilter from 'sentry/views/alerts/rules/teamFilter';
|
|
|
|
|
|
import ProjectCard from './projectCard';
|
|
|
import Resources from './resources';
|
|
|
import TeamSection from './teamSection';
|
|
|
+import {getTeamParams} from './utils';
|
|
|
|
|
|
type Props = {
|
|
|
api: Client;
|
|
@@ -43,14 +43,21 @@ type Props = {
|
|
|
teams: TeamWithProjects[];
|
|
|
} & RouteComponentProps<{orgId: string}, {}>;
|
|
|
|
|
|
-function Dashboard({teams, params, organization, loadingTeams, error}: Props) {
|
|
|
+function Dashboard({
|
|
|
+ teams,
|
|
|
+ params,
|
|
|
+ organization,
|
|
|
+ loadingTeams,
|
|
|
+ error,
|
|
|
+ router,
|
|
|
+ location,
|
|
|
+}: Props) {
|
|
|
useEffect(() => {
|
|
|
return function cleanup() {
|
|
|
ProjectsStatsStore.reset();
|
|
|
};
|
|
|
}, []);
|
|
|
const [projectQuery, setProjectQuery] = useState('');
|
|
|
- const [currentTeam, setCurrentTeam] = useState('');
|
|
|
const debouncedSearchQuery = useCallback(
|
|
|
debounce(handleSearch, DEFAULT_DEBOUNCE_DURATION),
|
|
|
[]
|
|
@@ -64,25 +71,31 @@ function Dashboard({teams, params, organization, loadingTeams, error}: Props) {
|
|
|
return <LoadingError message={t('An error occurred while fetching your projects')} />;
|
|
|
}
|
|
|
|
|
|
- const theme = useTheme();
|
|
|
- const isSuperuser = isActiveSuperuser();
|
|
|
+ const canCreateProjects = organization.access.includes('project:admin');
|
|
|
+ const canJoinTeam = organization.access.includes('team:read');
|
|
|
+ const hasTeamAdminAccess = organization.access.includes('team:admin');
|
|
|
+ const hasProjectAccess = organization.access.includes('project:read');
|
|
|
+ const hasProjectRedesign = organization.features.includes('projects-page-redesign');
|
|
|
|
|
|
- const filteredTeams = teams.filter(team => team.projects.length);
|
|
|
- filteredTeams.sort((team1, team2) => team1.slug.localeCompare(team2.slug));
|
|
|
+ const selectedTeams = new Set(getTeamParams(location ? location.query.team : ''));
|
|
|
+ const filteredTeams = teams.filter(team =>
|
|
|
+ hasProjectRedesign ? selectedTeams.has(team.id) : team.projects.length
|
|
|
+ );
|
|
|
|
|
|
+ if (!hasProjectRedesign) {
|
|
|
+ filteredTeams.sort((team1, team2) => team1.slug.localeCompare(team2.slug));
|
|
|
+ }
|
|
|
+ const filteredTeamProjects = uniqBy(
|
|
|
+ flatten((filteredTeams ?? teams).map(team => team.projects)),
|
|
|
+ 'id'
|
|
|
+ );
|
|
|
const projects = uniqBy(flatten(teams.map(teamObj => teamObj.projects)), 'id');
|
|
|
- const currentProjects = filteredTeams.find(team => team.id === currentTeam)?.projects;
|
|
|
+ const currentProjects = !selectedTeams.size ? projects : filteredTeamProjects;
|
|
|
const filteredProjects = (currentProjects ?? projects).filter(project =>
|
|
|
project.slug.includes(projectQuery)
|
|
|
);
|
|
|
const favorites = projects.filter(project => project.isBookmarked);
|
|
|
|
|
|
- const canCreateProjects = organization.access.includes('project:admin');
|
|
|
- const canJoinTeam = organization.access.includes('team:read');
|
|
|
- const hasTeamAdminAccess = organization.access.includes('team:admin');
|
|
|
- const hasProjectAccess = organization.access.includes('project:read');
|
|
|
- const hasProjectRedesign = organization.features.includes('projects-page-redesign');
|
|
|
-
|
|
|
const showEmptyMessage = projects.length === 0 && favorites.length === 0;
|
|
|
const showResources = projects.length === 1 && !projects[0].firstEvent;
|
|
|
|
|
@@ -90,9 +103,21 @@ function Dashboard({teams, params, organization, loadingTeams, error}: Props) {
|
|
|
setProjectQuery(searchQuery);
|
|
|
}
|
|
|
|
|
|
- function handleChange(newValue) {
|
|
|
- const updatedTeam = newValue ? newValue.actor.id : '';
|
|
|
- setCurrentTeam(updatedTeam);
|
|
|
+ function handleChangeFilter(sectionId: string, activeFilters: Set<string>) {
|
|
|
+ const {...currentQuery} = location.query;
|
|
|
+
|
|
|
+ let team = currentQuery.team;
|
|
|
+ if (sectionId === 'teams') {
|
|
|
+ team = activeFilters.size ? [...activeFilters] : '';
|
|
|
+ }
|
|
|
+
|
|
|
+ router.push({
|
|
|
+ pathname: location.pathname,
|
|
|
+ query: {
|
|
|
+ ...currentQuery,
|
|
|
+ team: team.length === 0 ? '' : team,
|
|
|
+ },
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
if (showEmptyMessage) {
|
|
@@ -107,144 +132,104 @@ function Dashboard({teams, params, organization, loadingTeams, error}: Props) {
|
|
|
{projects.length > 0 && (
|
|
|
<Fragment>
|
|
|
<ProjectsHeader>
|
|
|
- <PageHeading>{t('Projects')}</PageHeading>
|
|
|
- <ButtonContainer>
|
|
|
- {hasProjectRedesign && (
|
|
|
+ <Title>
|
|
|
+ <PageHeading>{t('Projects')}</PageHeading>
|
|
|
+ </Title>
|
|
|
+ <Layout.HeaderActions>
|
|
|
+ <ButtonContainer>
|
|
|
+ {hasProjectRedesign && (
|
|
|
+ <Button
|
|
|
+ icon={<IconUser size="xs" />}
|
|
|
+ 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')}
|
|
|
+ </Button>
|
|
|
+ )}
|
|
|
<Button
|
|
|
- size="small"
|
|
|
- icon={<IconUser size="xs" />}
|
|
|
+ priority={hasProjectRedesign ? 'primary' : 'default'}
|
|
|
+ disabled={!canCreateProjects}
|
|
|
title={
|
|
|
- canJoinTeam
|
|
|
- ? undefined
|
|
|
- : t('You do not have permission to join a team.')
|
|
|
+ !canCreateProjects
|
|
|
+ ? t('You do not have permission to create projects')
|
|
|
+ : undefined
|
|
|
}
|
|
|
- disabled={!canJoinTeam}
|
|
|
- to={`/settings/${organization.slug}/teams/`}
|
|
|
- data-test-id="join-team"
|
|
|
+ to={`/organizations/${organization.slug}/projects/new/`}
|
|
|
+ icon={<IconAdd size="xs" isCircled />}
|
|
|
+ data-test-id="create-project"
|
|
|
>
|
|
|
- {t('Join a Team')}
|
|
|
+ {t('Create Project')}
|
|
|
</Button>
|
|
|
- )}
|
|
|
- <Button
|
|
|
- size="small"
|
|
|
- priority={hasProjectRedesign ? 'primary' : 'default'}
|
|
|
- disabled={!canCreateProjects}
|
|
|
- title={
|
|
|
- !canCreateProjects
|
|
|
- ? t('You do not have permission to create projects')
|
|
|
- : undefined
|
|
|
- }
|
|
|
- to={`/organizations/${organization.slug}/projects/new/`}
|
|
|
- icon={<IconAdd size="xs" isCircled />}
|
|
|
- data-test-id="create-project"
|
|
|
- >
|
|
|
- {t('Create Project')}
|
|
|
- </Button>
|
|
|
- </ButtonContainer>
|
|
|
+ </ButtonContainer>
|
|
|
+ </Layout.HeaderActions>
|
|
|
</ProjectsHeader>
|
|
|
- {hasProjectRedesign && (
|
|
|
- <SearchAndSelectorWrapper>
|
|
|
- <StyledSearchBar
|
|
|
- defaultQuery=""
|
|
|
- placeholder={t('Search for projects by name')}
|
|
|
- onChange={debouncedSearchQuery}
|
|
|
- query={projectQuery}
|
|
|
- />
|
|
|
- <StyledTeamSelector
|
|
|
- name="select-team"
|
|
|
- aria-label="select-team"
|
|
|
- inFieldLabel={t('Team: ')}
|
|
|
- placeholder={t('My Teams')}
|
|
|
- value={currentTeam}
|
|
|
- onChange={choice => handleChange(choice)}
|
|
|
- teamFilter={isSuperuser ? undefined : filterTeam => filterTeam.isMember}
|
|
|
- useId
|
|
|
- clearable
|
|
|
- styles={{
|
|
|
- placeholder: (provided: any) => ({
|
|
|
- ...provided,
|
|
|
- paddingLeft: space(0.5),
|
|
|
- ':before': {
|
|
|
- ...provided[':before'],
|
|
|
- color: theme.textColor,
|
|
|
- },
|
|
|
- }),
|
|
|
- singleValue(provided: any) {
|
|
|
- const custom = {
|
|
|
- display: 'flex',
|
|
|
- justifyContent: 'space-between',
|
|
|
- alignItems: 'center',
|
|
|
- fontSize: theme.fontSizeMedium,
|
|
|
- ':before': {
|
|
|
- ...provided[':before'],
|
|
|
- color: theme.textColor,
|
|
|
- marginRight: space(1.5),
|
|
|
- marginLeft: space(0.5),
|
|
|
- },
|
|
|
- };
|
|
|
- return {...provided, ...custom};
|
|
|
- },
|
|
|
- input: (provided: any, state: any) => ({
|
|
|
- ...provided,
|
|
|
- display: 'grid',
|
|
|
- gridTemplateColumns: 'max-content 1fr',
|
|
|
- alignItems: 'center',
|
|
|
- marginRight: space(0.25),
|
|
|
- gridGap: space(1.5),
|
|
|
- ':before': {
|
|
|
- backgroundColor: state.theme.backgroundSecondary,
|
|
|
- height: 24,
|
|
|
- width: 38,
|
|
|
- borderRadius: 3,
|
|
|
- content: '""',
|
|
|
- display: 'block',
|
|
|
- },
|
|
|
- }),
|
|
|
- }}
|
|
|
- />
|
|
|
- </SearchAndSelectorWrapper>
|
|
|
- )}
|
|
|
+ <Body hasProjectRedesign={hasProjectRedesign}>
|
|
|
+ <Layout.Main fullWidth>
|
|
|
+ {hasProjectRedesign && (
|
|
|
+ <SearchAndSelectorWrapper>
|
|
|
+ <TeamFilter
|
|
|
+ selectedTeams={selectedTeams}
|
|
|
+ handleChangeFilter={handleChangeFilter}
|
|
|
+ showIsMemberTeams
|
|
|
+ showMyTeamsAndUnassigned={false}
|
|
|
+ showMyTeamsDescription
|
|
|
+ />
|
|
|
+ <StyledSearchBar
|
|
|
+ defaultQuery=""
|
|
|
+ placeholder={t('Search for projects by name')}
|
|
|
+ onChange={debouncedSearchQuery}
|
|
|
+ query={projectQuery}
|
|
|
+ />
|
|
|
+ </SearchAndSelectorWrapper>
|
|
|
+ )}
|
|
|
+ {hasProjectRedesign ? (
|
|
|
+ <LazyLoad once debounce={50} height={300} offset={300}>
|
|
|
+ <ProjectCards>
|
|
|
+ {filteredProjects.map(project => (
|
|
|
+ <ProjectCard
|
|
|
+ data-test-id={project.slug}
|
|
|
+ key={project.slug}
|
|
|
+ project={project}
|
|
|
+ hasProjectAccess={hasProjectAccess}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ </ProjectCards>
|
|
|
+ </LazyLoad>
|
|
|
+ ) : (
|
|
|
+ filteredTeams.map((team, index) => (
|
|
|
+ <LazyLoad key={team.slug} once debounce={50} height={300} offset={300}>
|
|
|
+ <TeamSection
|
|
|
+ orgId={params.orgId}
|
|
|
+ team={team}
|
|
|
+ showBorder={index !== teams.length - 1}
|
|
|
+ title={
|
|
|
+ hasTeamAdminAccess ? (
|
|
|
+ <TeamLink
|
|
|
+ to={`/settings/${organization.slug}/teams/${team.slug}/`}
|
|
|
+ >
|
|
|
+ <IdBadge team={team} avatarSize={22} />
|
|
|
+ </TeamLink>
|
|
|
+ ) : (
|
|
|
+ <IdBadge team={team} avatarSize={22} />
|
|
|
+ )
|
|
|
+ }
|
|
|
+ projects={sortProjects(team.projects)}
|
|
|
+ access={new Set(organization.access)}
|
|
|
+ />
|
|
|
+ </LazyLoad>
|
|
|
+ ))
|
|
|
+ )}
|
|
|
+ {showResources && <Resources organization={organization} />}
|
|
|
+ </Layout.Main>
|
|
|
+ </Body>
|
|
|
</Fragment>
|
|
|
)}
|
|
|
-
|
|
|
- {hasProjectRedesign ? (
|
|
|
- <LazyLoad once debounce={50} height={300} offset={300}>
|
|
|
- <ProjectCardsContainer>
|
|
|
- <ProjectCards>
|
|
|
- {filteredProjects.map(project => (
|
|
|
- <ProjectCard
|
|
|
- data-test-id={project.slug}
|
|
|
- key={project.slug}
|
|
|
- project={project}
|
|
|
- hasProjectAccess={hasProjectAccess}
|
|
|
- />
|
|
|
- ))}
|
|
|
- </ProjectCards>
|
|
|
- </ProjectCardsContainer>
|
|
|
- </LazyLoad>
|
|
|
- ) : (
|
|
|
- filteredTeams.map((team, index) => (
|
|
|
- <LazyLoad key={team.slug} once debounce={50} height={300} offset={300}>
|
|
|
- <TeamSection
|
|
|
- orgId={params.orgId}
|
|
|
- team={team}
|
|
|
- showBorder={index !== teams.length - 1}
|
|
|
- title={
|
|
|
- hasTeamAdminAccess ? (
|
|
|
- <TeamLink to={`/settings/${organization.slug}/teams/${team.slug}/`}>
|
|
|
- <IdBadge team={team} avatarSize={22} />
|
|
|
- </TeamLink>
|
|
|
- ) : (
|
|
|
- <IdBadge team={team} avatarSize={22} />
|
|
|
- )
|
|
|
- }
|
|
|
- projects={sortProjects(team.projects)}
|
|
|
- access={new Set(organization.access)}
|
|
|
- />
|
|
|
- </LazyLoad>
|
|
|
- ))
|
|
|
- )}
|
|
|
- {showResources && <Resources organization={organization} />}
|
|
|
</Fragment>
|
|
|
);
|
|
|
}
|
|
@@ -260,37 +245,54 @@ const TeamLink = styled(Link)`
|
|
|
align-items: center;
|
|
|
`;
|
|
|
|
|
|
-const ProjectsHeader = styled('div')`
|
|
|
- padding: ${space(3)} ${space(4)} 0 ${space(4)};
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: space-between;
|
|
|
+const ProjectsHeader = styled(Layout.Header)`
|
|
|
+ border-bottom: none;
|
|
|
+`;
|
|
|
+
|
|
|
+const Title = styled(Layout.HeaderContent)`
|
|
|
+ margin-bottom: 0;
|
|
|
+ padding-top: ${space(0.5)};
|
|
|
`;
|
|
|
|
|
|
const ButtonContainer = styled('div')`
|
|
|
display: inline-flex;
|
|
|
gap: ${space(1)};
|
|
|
+ padding-top: ${space(0.5)};
|
|
|
`;
|
|
|
|
|
|
const SearchAndSelectorWrapper = styled('div')`
|
|
|
display: flex;
|
|
|
- gap: 16px;
|
|
|
+ gap: ${space(2)};
|
|
|
justify-content: flex-end;
|
|
|
align-items: flex-end;
|
|
|
+ margin-bottom: ${space(2)};
|
|
|
+
|
|
|
+ @media (max-width: ${p => p.theme.breakpoints[0]}) {
|
|
|
+ display: block;
|
|
|
+ }
|
|
|
+
|
|
|
+ @media (min-width: ${p => p.theme.breakpoints[3]}) {
|
|
|
+ display: flex;
|
|
|
+ }
|
|
|
`;
|
|
|
|
|
|
const StyledSearchBar = styled(SearchBar)`
|
|
|
- margin-left: 30px;
|
|
|
flex-grow: 1;
|
|
|
-`;
|
|
|
|
|
|
-const StyledTeamSelector = styled(TeamSelector)`
|
|
|
- margin: ${space(2)} 30px 0 0;
|
|
|
- width: 300px;
|
|
|
+ @media (max-width: ${p => p.theme.breakpoints[0]}) {
|
|
|
+ margin-top: ${space(1)};
|
|
|
+ }
|
|
|
`;
|
|
|
|
|
|
-const ProjectCardsContainer = styled('div')`
|
|
|
- padding: ${space(2)} 30px ${space(2)} 30px;
|
|
|
+const Body = styled(Layout.Body)<{hasProjectRedesign: boolean}>`
|
|
|
+ padding-top: ${space(2)} !important;
|
|
|
+ background-color: ${p => p.theme.surface100};
|
|
|
+
|
|
|
+ ${p =>
|
|
|
+ !p.hasProjectRedesign &&
|
|
|
+ `
|
|
|
+ padding: 0 !important;
|
|
|
+ `}
|
|
|
`;
|
|
|
|
|
|
const ProjectCards = styled('div')`
|