diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt index f0725561b7..5e16be8787 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/TestDataService.kt @@ -171,7 +171,7 @@ class TestDataService( tryUntilItDoesntBreakConstraint { executeInNewTransaction(transactionManager) { builder.data.userAccounts.forEach { - userAccountService.findActive(it.self.username)?.let { user -> + userAccountService.findAnyByUsername(it.self.username)?.let { user -> notificationService.deleteNotificationsOfUser(user.id) userAccountService.delete(user) } diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ContributorsTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ContributorsTestData.kt index b9d146ee32..eeb2857927 100644 --- a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ContributorsTestData.kt +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/ContributorsTestData.kt @@ -9,6 +9,8 @@ import java.util.Date class ContributorsTestData { lateinit var project: Project + lateinit var publicProject: Project + lateinit var publicEmptyProject: Project lateinit var admin: UserAccount lateinit var contributor: UserAccount lateinit var contributor2: UserAccount @@ -65,5 +67,17 @@ class ContributorsTestData { type = ProjectPermissionType.VIEW } }.self + + publicProject = + addProject(organizationOwner = adminBuilder.defaultOrganizationBuilder.self) { + name = "Contributors public project" + public = true + }.self + + publicEmptyProject = + addProject(organizationOwner = adminBuilder.defaultOrganizationBuilder.self) { + name = "Contributors public empty project" + public = true + }.self } } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt index 86dc8519a6..94b029fe24 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/UserAccountService.kt @@ -99,6 +99,10 @@ class UserAccountService( return userAccountRepository.findActiveOrDisabled(username) } + fun findAnyByUsername(username: String): UserAccount? { + return userAccountRepository.findByExactUsername(username).orElse(null) + } + operator fun get(username: String): UserAccount { return this.findActive(username) ?: throw NotFoundException(Message.USER_NOT_FOUND) } diff --git a/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/MembersCommunityE2eDataController.kt b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/MembersCommunityE2eDataController.kt new file mode 100644 index 0000000000..ad473939b7 --- /dev/null +++ b/backend/development/src/main/kotlin/io/tolgee/controllers/internal/e2eData/MembersCommunityE2eDataController.kt @@ -0,0 +1,55 @@ +package io.tolgee.controllers.internal.e2eData + +import io.tolgee.component.CurrentDateProvider +import io.tolgee.controllers.internal.InternalController +import io.tolgee.development.testDataBuilder.builders.TestDataBuilder +import io.tolgee.development.testDataBuilder.data.ContributorsTestData +import io.tolgee.model.activity.ActivityRevision +import jakarta.persistence.EntityManager +import org.springframework.beans.factory.annotation.Autowired +import java.util.Date + +@InternalController(["internal/e2e-data/members-community"]) +class MembersCommunityE2eDataController : AbstractE2eDataController() { + @Autowired + private lateinit var entityManager: EntityManager + + @Autowired + private lateinit var currentDateProvider: CurrentDateProvider + + private lateinit var data: ContributorsTestData + + override val testData: TestDataBuilder + get() = ContributorsTestData().also { data = it }.root + + override fun afterTestDataStored(data: TestDataBuilder) { + try { + listOf(this.data.contributor.id, this.data.contributor2.id).forEach { authorId -> + recordActivity(this.data.publicProject.id, authorId, FIRST_CONTRIBUTION_AT) + recordActivity(this.data.publicProject.id, authorId, LAST_CONTRIBUTION_AT) + } + } finally { + currentDateProvider.forcedDate = null + } + } + + private fun recordActivity( + projectId: Long, + authorId: Long, + at: Date, + ) { + currentDateProvider.forcedDate = at + entityManager.persist( + ActivityRevision().apply { + this.projectId = projectId + this.authorId = authorId + }, + ) + entityManager.flush() + } + + companion object { + private val FIRST_CONTRIBUTION_AT = Date(1_560_600_000_000) + private val LAST_CONTRIBUTION_AT = Date(1_623_758_400_000) + } +} diff --git a/e2e/cypress/common/apiCalls/testData/testData.ts b/e2e/cypress/common/apiCalls/testData/testData.ts index 8fb66cbb5c..a8ad680bad 100644 --- a/e2e/cypress/common/apiCalls/testData/testData.ts +++ b/e2e/cypress/common/apiCalls/testData/testData.ts @@ -25,6 +25,8 @@ export const communityContributionData = generateTestDataObject( 'community-contribution' ); +export const membersCommunityData = generateTestDataObject('members-community'); + export const organizationNewTestData = generateTestDataObject('organization-new'); diff --git a/e2e/cypress/e2e/projects/communityMembersTab.cy.ts b/e2e/cypress/e2e/projects/communityMembersTab.cy.ts new file mode 100644 index 0000000000..f5103818e7 --- /dev/null +++ b/e2e/cypress/e2e/projects/communityMembersTab.cy.ts @@ -0,0 +1,93 @@ +import { gcy, gcyAdvanced, visitProjectMembers } from '../../common/shared'; +import { login } from '../../common/apiCalls/common'; +import { + getProjectByNameFromTestData, + membersCommunityData, +} from '../../common/apiCalls/testData/testData'; +import { waitForGlobalLoading } from '../../common/loading'; + +describe('Community tab on the Members page', () => { + beforeEach(() => { + membersCommunityData.clean(); + }); + + afterEach(() => { + membersCommunityData.clean(); + }); + + it('shows Team and Community tabs on a public project and lists non-member contributors without email', () => { + membersCommunityData.generateStandard().then((res) => { + const project = getProjectByNameFromTestData( + res.body, + 'Contributors public project' + ); + login('admin@contributors.com'); + visitProjectMembers(project.id); + waitForGlobalLoading(); + + gcy('project-members-tab-team').should('be.visible'); + gcy('project-members-tab-community').should('be.visible').click(); + + gcy('project-contributor-item').should('have.length', 2); + gcyAdvanced({ + value: 'project-contributor-item', + name: 'Cora Contributor', + }).should('exist'); + gcyAdvanced({ + value: 'project-contributor-item', + name: 'Cody Contributor', + }).should('exist'); + + gcy('project-member-item').should('not.be.visible'); + + // Dates are seeded 2019 (first) and 2021 (last) by MembersCommunityE2eDataController. + gcy('project-contributor-item-first-contribution') + .first() + .should('be.visible') + .and('contain', '2019'); + gcy('project-contributor-item-last-contribution') + .first() + .should('be.visible') + .and('contain', '2021'); + + cy.contains('contributor@contributors.com').should('not.exist'); + cy.contains('contributor2@contributors.com').should('not.exist'); + + gcy('project-members-tab-team').click(); + gcy('project-member-item').should('be.visible'); + gcy('project-contributor-item').should('not.exist'); + }); + }); + + it('hides the tabs on a public project with no contributors and shows the member list', () => { + membersCommunityData.generateStandard().then((res) => { + const project = getProjectByNameFromTestData( + res.body, + 'Contributors public empty project' + ); + login('admin@contributors.com'); + visitProjectMembers(project.id); + waitForGlobalLoading(); + + gcy('project-member-item').should('be.visible'); + gcy('project-members-tab-team').should('not.exist'); + gcy('project-members-tab-community').should('not.exist'); + }); + }); + + it('hides the tabs on a private project and shows the member list', () => { + membersCommunityData.generateStandard().then((res) => { + const project = getProjectByNameFromTestData( + res.body, + 'Contributors project' + ); + login('admin@contributors.com'); + visitProjectMembers(project.id); + waitForGlobalLoading(); + + gcy('project-member-item').should('be.visible'); + gcy('project-members-tab-team').should('not.exist'); + gcy('project-members-tab-community').should('not.exist'); + }); + }); +}); diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index 1e8524d53b..3a76c89049 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -552,6 +552,9 @@ declare namespace DataCy { "project-branch-merge-change": true; "project-branch-merge-delete-branch-checkbox": true; "project-branch-merge-detail": true; + "project-contributor-item": true; + "project-contributor-item-first-contribution": true; + "project-contributor-item-last-contribution": true; "project-dashboard-activity-chart": true; "project-dashboard-activity-list": true; "project-dashboard-base-word-count": true; @@ -589,6 +592,8 @@ declare namespace DataCy { "project-member-item": true; "project-member-revoke-button": true; "project-members-invitation-item": true; + "project-members-tab-community": true; + "project-members-tab-team": true; "project-menu-item": true; "project-menu-item-ai": true; "project-menu-item-branches": true; diff --git a/webapp/src/views/projects/members/ProjectMembersView.tsx b/webapp/src/views/projects/members/ProjectMembersView.tsx index 354a414b16..260880b302 100644 --- a/webapp/src/views/projects/members/ProjectMembersView.tsx +++ b/webapp/src/views/projects/members/ProjectMembersView.tsx @@ -1,5 +1,5 @@ import { FunctionComponent, useEffect, useState } from 'react'; -import { Box, Button, Typography } from '@mui/material'; +import { Box, Button, styled, Tab, Tabs, Typography } from '@mui/material'; import { useTranslate } from '@tolgee/react'; import { PaginatedHateoasList } from 'tg.component/common/list/PaginatedHateoasList'; @@ -14,11 +14,21 @@ import SearchField from 'tg.component/common/form/fields/SearchField'; import { useUrlSearchState } from 'tg.hooks/useUrlSearchState'; import { AgencyFilter } from './component/AgencyFilter'; import { MemberItem } from './component/MemberItem'; +import { ContributorItem } from './component/ContributorItem'; import { InviteDialog } from './component/InviteDialog'; import { InvitationItem } from './component/InvitationItem'; import { BaseProjectView } from '../BaseProjectView'; import { useConfig, useEnabledFeatures } from 'tg.globalContext/helpers'; +const StyledTabs = styled(Tabs)` + margin-bottom: -1px; +`; + +const StyledTabWrapper = styled(Box)` + border-bottom: 1px solid ${({ theme }) => theme.palette.divider1}; + margin-bottom: ${({ theme }) => theme.spacing(2)}; +`; + export const ProjectMembersView: FunctionComponent< React.PropsWithChildren > = () => { @@ -30,10 +40,14 @@ export const ProjectMembersView: FunctionComponent< const { t } = useTranslate(); + const [activeTab, setActiveTab] = useUrlSearchState('tab', { + defaultVal: 'team', + }); const [inviteOpen, setInviteOpen] = useState(false); const [search, setSearch] = useState(''); const [page, setPage] = useState(0); - const [filterAgecy, setFilterAgency] = useUrlSearchState('agency', { + const [contributorsPage, setContributorsPage] = useState(0); + const [filterAgency, setFilterAgency] = useUrlSearchState('agency', { array: true, defaultVal: [], }); @@ -46,7 +60,7 @@ export const ProjectMembersView: FunctionComponent< page, sort: ['name'], search, - filterAgency: filterAgecy?.map((a) => Number(a)), + filterAgency: filterAgency?.map((a) => Number(a)), }, options: { keepPreviousData: true, @@ -62,6 +76,25 @@ export const ProjectMembersView: FunctionComponent< }, }); + const contributorsLoadable = useApiQuery({ + url: '/v2/projects/{projectId}/contributors', + method: 'get', + path: { projectId: project.id }, + query: { + page: contributorsPage, + }, + options: { + enabled: project.public, + keepPreviousData: true, + }, + }); + + const hasContributors = + (contributorsLoadable.data?.page?.totalElements ?? 0) > 0; + const currentTab = activeTab === 'community' ? 'community' : 'team'; + const showTabs = project.public && hasContributors; + const showCommunity = showTabs && currentTab === 'community'; + const { satisfiesPermission } = useProjectPermissions(); const canEditMembers = satisfiesPermission('members.edit'); @@ -88,103 +121,132 @@ export const ProjectMembersView: FunctionComponent< > - - - - {t('invitations_title')} - {canEditMembers && ( - - )} - - - } - emptyPlaceholder={ - - - {t('invite_user_nothing_found')} - - - } - /> - - - - setInviteOpen(false)} - open={inviteOpen} - /> - - - - - - + setActiveTab(value)} > - - {t('project_menu_members')} - + + + + + )} + + + - {showAgencyFilter && ( - Number(a))} - onChange={(value) => - setFilterAgency(value.map((a) => String(a))) - } - /> + {t('invitations_title')} + {canEditMembers && ( + )} - + + } + emptyPlaceholder={ + + + {t('invite_user_nothing_found')} + + + } + /> - - - {t('global_nothing_found')} - + + + setInviteOpen(false)} + open={inviteOpen} + /> + + + + + + + + {t('project_menu_members')} + + + {showAgencyFilter && ( + Number(a))} + onChange={(value) => + setFilterAgency(value.map((a) => String(a))) + } + /> + )} + - } - renderItem={(u) => } - /> - - + + + + {t('global_nothing_found')} + + + } + renderItem={(u) => } + /> + + + + + {showCommunity && ( + } + /> + )} diff --git a/webapp/src/views/projects/members/component/ContributorItem.tsx b/webapp/src/views/projects/members/component/ContributorItem.tsx new file mode 100644 index 0000000000..6f4941d3ee --- /dev/null +++ b/webapp/src/views/projects/members/component/ContributorItem.tsx @@ -0,0 +1,92 @@ +import { T, useTranslate } from '@tolgee/react'; +import { styled } from '@mui/material'; + +import { components } from 'tg.service/apiSchema.generated'; +import { AvatarImg } from 'tg.component/common/avatar/AvatarImg'; +import { useDateFormatter } from 'tg.hooks/useLocale'; + +type ContributorModel = components['schemas']['ContributorModel']; + +const StyledListItem = styled('div')` + display: flex; + border-bottom: 1px solid ${({ theme }) => theme.palette.divider1}; + &:last-child { + border-bottom: 0; + } + position: relative; + padding: ${({ theme }) => theme.spacing(1)}; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; +`; + +const StyledItemUser = styled('div')` + display: flex; + margin-left: ${({ theme }) => theme.spacing(1)}; + flex-grow: 1; + align-items: center; +`; + +const StyledItemText = styled('div')` + flex-grow: 1; + padding: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledItemDates = styled('div')` + display: flex; + gap: ${({ theme }) => theme.spacing(2)}; + align-items: center; + flex-wrap: wrap; + color: ${({ theme }) => theme.palette.text.secondary}; + font-size: ${({ theme }) => theme.typography.caption.fontSize}; +`; + +type Props = { + contributor: ContributorModel; +}; + +export const ContributorItem: React.FC> = ({ + contributor, +}) => { + const { t } = useTranslate(); + const formatDate = useDateFormatter(); + const firstContribution = formatDate( + new Date(contributor.firstContributionAt), + { dateStyle: 'medium' } + ); + const lastContribution = formatDate( + new Date(contributor.lastContributionAt), + { dateStyle: 'medium' } + ); + + return ( + + + + + {contributor.name || + t('project_contributor_unnamed', 'Unnamed contributor')} + + + + + + + + + + + + ); +};