From 118f94eff9e6b586d6a93ad1427bbb3069b91260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Tue, 30 Jun 2026 15:22:32 +0200 Subject: [PATCH 1/7] feat: community projects page and switcher navigation entry Projects-page redesign lane of Community Translation v1.0: - the org switcher gains a "Community translation" entry that navigates to /community-projects instead of switching organization (suppressed while already on the community surface) - projects-list pages render a "Projects" title with the org switcher inline next to it - new email-gated /community-projects view lists cross-org public projects via /v2/public/projects/with-stats using the shared ProjectsList (variant="public"); no banner, no "+ PROJECT" --- .../communityProjectsNavigation.cy.ts | 241 ++++++++++++++++++ e2e/cypress/support/dataCyType.d.ts | 3 + webapp/src/component/RootRouter.tsx | 4 + .../component/SwitchPopover/SwitchPopover.tsx | 27 +- webapp/src/component/layout/BaseView.tsx | 2 +- webapp/src/component/layout/HeaderBar.tsx | 126 ++++++--- .../CommunityTranslationItem.tsx | 22 ++ .../OrganizationPopover.tsx | 16 +- .../organizationSwitch/OrganizationSwitch.tsx | 82 +++--- webapp/src/constants/links.tsx | 2 + .../views/projects/CommunityProjectsView.tsx | 89 +++++++ webapp/src/views/projects/ProjectListView.tsx | 16 +- .../projects/useLatchedSearchVisibility.ts | 25 ++ 13 files changed, 567 insertions(+), 88 deletions(-) create mode 100644 e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts create mode 100644 webapp/src/component/organizationSwitch/CommunityTranslationItem.tsx create mode 100644 webapp/src/views/projects/CommunityProjectsView.tsx create mode 100644 webapp/src/views/projects/useLatchedSearchVisibility.ts diff --git a/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts b/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts new file mode 100644 index 00000000000..a39ea36b36c --- /dev/null +++ b/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts @@ -0,0 +1,241 @@ +import { HOST } from '../../common/constants'; +import { + createUser, + deleteAllEmails, + deleteUserSql, + disableEmailVerification, + enableEmailVerification, + login, + logout, + setBypassSeatCountCheck, +} from '../../common/apiCalls/common'; +import { + organizationTestData, + publicProjectsData, +} from '../../common/apiCalls/testData/testData'; +import { gcy, switchToOrganization } from '../../common/shared'; +import { waitForGlobalLoading } from '../../common/loading'; + +describe('Community projects navigation', () => { + let organizationData: Record; + + beforeEach(() => { + setBypassSeatCountCheck(true); + login(); + organizationTestData.clean({ timeout: 120000 }); + organizationTestData.generate().then((res) => { + organizationData = res.body as any; + visitProjects(); + }); + }); + + afterEach(() => { + organizationTestData.clean(); + setBypassSeatCountCheck(false); + }); + + const visitProjects = () => { + cy.visit(`${HOST}/projects`); + cy.waitForDom(); + }; + + const openSwitch = () => { + cy.waitForDom(); + gcy('organization-switch').click(); + cy.waitForDom(); + }; + + const visitCommunity = () => { + cy.visit(`${HOST}/community-projects`); + cy.waitForDom(); + }; + + it('navigates to the community page via the dropdown entry (mouse)', () => { + openSwitch(); + gcy('organization-switch-community').click(); + cy.location('pathname').should('eq', '/community-projects'); + }); + + it('navigates to the community page via the dropdown entry (keyboard)', () => { + openSwitch(); + gcy('organization-switch-community').focus().type('{enter}'); + cy.location('pathname').should('eq', '/community-projects'); + }); + + it('shows the community chrome: community chip, no add button', () => { + visitCommunity(); + gcy('community-projects-view').should('be.visible'); + gcy('organization-switch') + .findDcy('community-translation-item') + .should('be.visible'); + gcy('global-plus-button').should('not.exist'); + }); + + it('returns to the org list when selecting an org from the community switcher', () => { + visitCommunity(); + switchToOrganization('Microsoft'); + cy.location('pathname').should('eq', '/'); + cy.waitForDom(); + gcy('organization-switch').contains('Microsoft').should('be.visible'); + + cy.reload(); + cy.waitForDom(); + gcy('organization-switch').contains('Microsoft').should('be.visible'); + }); + + it('does not navigate away when selecting an org from the normal list switcher', () => { + switchToOrganization('Microsoft'); + cy.location('pathname').should('eq', '/'); + }); + + it('renders the Projects title with the inline switcher on both list pages', () => { + gcy('global-base-view-title').contains('Projects').should('be.visible'); + gcy('global-base-view-title') + .findDcy('organization-switch') + .should('be.visible'); + + visitCommunity(); + gcy('global-base-view-title').contains('Projects').should('be.visible'); + gcy('global-base-view-title') + .findDcy('organization-switch') + .should('be.visible'); + }); + + it('highlights no org row while on the community page but still offers the community entry', () => { + visitCommunity(); + openSwitch(); + gcy('switch-popover-item').should('exist'); + gcy('switch-popover-item').filter('.Mui-selected').should('not.exist'); + gcy('organization-switch-community').should('be.visible'); + }); + + it('offers the community entry from a switcher outside the projects pages', () => { + cy.visit( + `${HOST}/organizations/${organizationData['Tolgee'].slug}/members` + ); + openSwitch(); + gcy('switch-popover-item').should('exist'); + gcy('organization-switch-community').should('be.visible'); + }); + + it('shows the empty state and hides search when there are no public projects', () => { + publicProjectsData.clean(); + visitCommunity(); + waitForGlobalLoading(); + gcy('community-projects-view').should('be.visible'); + gcy('global-paginated-list').should('be.visible'); + gcy('dashboard-projects-list-item').should('not.exist'); + gcy('global-list-search').should('not.exist'); + }); + + it('closes the popover on Escape from the footer entry', () => { + openSwitch(); + gcy('organization-switch-community').focus().type('{esc}'); + gcy('switch-popover-item').should('not.exist'); + gcy('organization-switch-community').should('not.exist'); + }); +}); + +describe('Community projects email-verification gate', () => { + const email = 'community-projects-unverified@doe.com'; + const password = 'verysecurepassword'; + const changedEmail = 'community-projects-changed@doe.com'; + + beforeEach(() => { + enableEmailVerification(); + deleteUserSql(email); + deleteUserSql(changedEmail); + deleteAllEmails(); + createUser(email, password); + login(email, password); + // Changing the email while verification is enabled puts the authenticated session into the + // awaiting-verification state, which is what unverified users see. + cy.visit(`${HOST}/account/profile`); + cy.get('form').findInputByName('email').clear().type(changedEmail); + cy.xpath("//*[@name='currentPassword']").clear().type(password); + gcy('global-form-save-button').click(); + waitForGlobalLoading(); + }); + + afterEach(() => { + deleteUserSql(email); + deleteUserSql(changedEmail); + deleteAllEmails(); + disableEmailVerification(); + }); + + it('shows EmailNotVerifiedView instead of the switcher for unverified users', () => { + cy.visit(`${HOST}/community-projects`); + waitForGlobalLoading(); + gcy('resend-email-button').should('be.visible'); + gcy('organization-switch').should('not.exist'); + }); +}); + +describe('Community projects list content', () => { + beforeEach(() => { + publicProjectsData.clean(); + publicProjectsData.generate(); + login('publicProjectsUser'); + cy.visit(`${HOST}/community-projects`); + waitForGlobalLoading(); + }); + + afterEach(() => { + publicProjectsData.clean(); + }); + + it('lists public projects across orgs with the public badge, hiding private ones', () => { + gcy('dashboard-projects-list-item').should('have.length', 6); + gcy('project-list-public-badge').should('have.length', 6); + cy.contains('Community Alpha').should('be.visible'); + cy.contains('Community Zeta').should('be.visible'); + cy.contains('Private project').should('not.exist'); + }); + + it('narrows the community list with search', () => { + gcy('global-list-search').should('exist'); + gcy('global-list-search').find('input').type('Alpha'); + waitForGlobalLoading(); + gcy('dashboard-projects-list-item').should('have.length', 1); + cy.contains('Community Alpha').should('be.visible'); + }); + + it('keeps the search field visible after clearing an active search', () => { + gcy('global-list-search').find('input').type('Alpha'); + waitForGlobalLoading(); + gcy('dashboard-projects-list-item').should('have.length', 1); + gcy('global-list-search').find('input').clear(); + // The length-6 assertion must come first: it retries until the debounced cleared-search + // refetch lands, so the focus check below runs only after the field has (or hasn't) remounted. + gcy('dashboard-projects-list-item').should('have.length', 6); + gcy('global-list-search').find('input').should('be.focused'); + }); +}); + +describe('Community projects search threshold', () => { + beforeEach(() => { + publicProjectsData.clean(); + publicProjectsData.generateFew(); + login('publicProjectsUser'); + cy.visit(`${HOST}/community-projects`); + waitForGlobalLoading(); + }); + + afterEach(() => { + publicProjectsData.clean(); + }); + + it('hides the search field at or below the project threshold', () => { + gcy('dashboard-projects-list-item').should('have.length', 5); + gcy('global-list-search').should('not.exist'); + }); +}); + +describe('Community projects access', () => { + it('redirects an unauthenticated visitor to login', () => { + logout(); + cy.visit(`${HOST}/community-projects`); + cy.location('pathname').should('include', '/login'); + }); +}); diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index 188d5ec1f2a..0afabb7c66c 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -146,7 +146,9 @@ declare namespace DataCy { "comment-menu-needs-resolution": true; "comment-resolve": true; "comment-text": true; + "community-projects-view": true; "community-translation-banner": true; + "community-translation-item": true; "content-delivery-add-button": true; "content-delivery-auto-publish-checkbox": true; "content-delivery-delete-button": true; @@ -505,6 +507,7 @@ declare namespace DataCy { "organization-role-select-item": true; "organization-side-menu": true; "organization-switch": true; + "organization-switch-community": true; "palette-color": true; "pat-expiry-info": true; "pat-list-item": true; diff --git a/webapp/src/component/RootRouter.tsx b/webapp/src/component/RootRouter.tsx index 2a9ddf67dd9..2e7fb539737 100644 --- a/webapp/src/component/RootRouter.tsx +++ b/webapp/src/component/RootRouter.tsx @@ -3,6 +3,7 @@ import { Redirect, Route, Switch } from 'react-router-dom'; import { LINKS } from 'tg.constants/links'; import { ProjectsRouter } from 'tg.views/projects/ProjectsRouter'; +import { CommunityProjectsView } from 'tg.views/projects/CommunityProjectsView'; import { UserSettingsRouter } from 'tg.views/userSettings/UserSettingsRouter'; import { OrganizationsRouter } from 'tg.views/organizations/OrganizationsRouter'; import { AdministrationView } from 'tg.views/administration/AdministrationView'; @@ -110,6 +111,9 @@ export const RootRouter = () => { + + + diff --git a/webapp/src/component/SwitchPopover/SwitchPopover.tsx b/webapp/src/component/SwitchPopover/SwitchPopover.tsx index dd696268e4b..dd33f655c6b 100644 --- a/webapp/src/component/SwitchPopover/SwitchPopover.tsx +++ b/webapp/src/component/SwitchPopover/SwitchPopover.tsx @@ -10,6 +10,7 @@ import { styled, Typography, Button, + Divider, } from '@mui/material'; import { Plus } from '@untitled-ui/icons-react'; import { useTranslate } from '@tolgee/react'; @@ -65,7 +66,7 @@ type SwitchPopoverProps = { onClose: () => void; onSelect: (item: T) => void; anchorEl: HTMLElement; - selectedId: number; + selectedId?: number; // Data items: T[]; @@ -84,6 +85,12 @@ type SwitchPopoverProps = { onAddNew?: () => void; addNewTooltip?: string; + footerAction?: { + content: React.ReactNode; + onClick: () => void; + dataCy?: string; + }; + // Search callback for a parent to handle onSearchChange: (search: string) => void; }; @@ -105,6 +112,7 @@ export function SwitchPopover({ searchThreshold = DEFAULT_SEARCH_THRESHOLD, onAddNew, addNewTooltip, + footerAction, onSearchChange, }: SwitchPopoverProps) { const [inputValue, setInputValue] = useState(''); @@ -222,6 +230,23 @@ export function SwitchPopover({ )} /> + {footerAction && ( + <> + + + { + footerAction.onClick(); + onClose(); + }} + > + {footerAction.content} + + + + )} ); diff --git a/webapp/src/component/layout/BaseView.tsx b/webapp/src/component/layout/BaseView.tsx index 968c51e89cf..84914d9fc14 100644 --- a/webapp/src/component/layout/BaseView.tsx +++ b/webapp/src/component/layout/BaseView.tsx @@ -91,7 +91,7 @@ export const BaseView: FC = (props) => { )} - + void; searchPlaceholder?: string; customHeader?: ReactNode; + standaloneTitle?: boolean; switcher?: ReactNode; maxWidth?: BaseViewWidth; initialSearch?: string; @@ -51,58 +52,99 @@ export const HeaderBar: React.VFC = (props) => { if (props.headerBarDisable || !displayHeader) { return null; } + + const hasSearch = typeof props.onSearch === 'function'; + const stackedSearch = Boolean(props.standaloneTitle) && hasSearch; + + const titleContent = ( + + {props.title !== undefined && ( + + {props.title} + + )} + {props.titleAdornment} + + ); + + const searchField = hasSearch && ( + + + + ); + + const switcherAndButtons = ( + + {props.switcher && ( + + {props.switcher} + + )} + {props.customButtons && + props.customButtons.map((button, index) => ( + + {button} + + ))} + + ); + + const addButton = props.addComponent + ? props.addComponent + : (props.onAdd || props.addLinkTo) && ( + + ); + return ( - {props.customHeader || ( - - - {props.title !== undefined && ( - - {props.title} - - )} - {props.titleAdornment} - {typeof props.onSearch === 'function' && ( - - - - )} + {props.customHeader || + (stackedSearch ? ( + + + {titleContent} + {switcherAndButtons} + + + {searchField} + {addButton} + - - {props.switcher && ( - - {props.switcher} - - )} - {props.customButtons && - props.customButtons.map((button, index) => ( - - {button} - - ))} - {props.addComponent - ? props.addComponent - : (props.onAdd || props.addLinkTo) && ( - - )} + ) : ( + + + {titleContent} + {searchField} + + + {switcherAndButtons} + {addButton} + - - )} + ))} ); diff --git a/webapp/src/component/organizationSwitch/CommunityTranslationItem.tsx b/webapp/src/component/organizationSwitch/CommunityTranslationItem.tsx new file mode 100644 index 00000000000..2bf7e1aef2e --- /dev/null +++ b/webapp/src/component/organizationSwitch/CommunityTranslationItem.tsx @@ -0,0 +1,22 @@ +import { Box, styled } from '@mui/material'; +import { Users01 } from '@untitled-ui/icons-react'; +import { useTranslate } from '@tolgee/react'; + +const StyledItem = styled('div')` + display: grid; + grid-auto-flow: column; + gap: ${({ theme }) => theme.spacing(0.75)}; + align-items: center; +`; + +export const CommunityTranslationItem = () => { + const { t } = useTranslate(); + return ( + + + + + {t('community_translation_switch_item')} + + ); +}; diff --git a/webapp/src/component/organizationSwitch/OrganizationPopover.tsx b/webapp/src/component/organizationSwitch/OrganizationPopover.tsx index 7a6c565d751..7c031d7fb7f 100644 --- a/webapp/src/component/organizationSwitch/OrganizationPopover.tsx +++ b/webapp/src/component/organizationSwitch/OrganizationPopover.tsx @@ -2,6 +2,7 @@ import { useState, useCallback } from 'react'; import { useTranslate } from '@tolgee/react'; import { OrganizationItem } from './OrganizationItem'; +import { CommunityTranslationItem } from './CommunityTranslationItem'; import { components } from 'tg.service/apiSchema.generated'; import { useApiInfiniteQuery } from 'tg.service/http/useQueryApi'; import { useConfig, useIsAdmin } from 'tg.globalContext/helpers'; @@ -17,6 +18,8 @@ type Props = { selected: OrganizationModel | undefined; onAddNew: () => void; ownedOnly?: boolean; + onCommunityNavigate?: () => void; + communitySelected?: boolean; }; export const OrganizationPopover: React.FC = ({ @@ -27,6 +30,8 @@ export const OrganizationPopover: React.FC = ({ selected, onAddNew, ownedOnly, + onCommunityNavigate, + communitySelected, }) => { const { t } = useTranslate(); const [search, setSearch] = useState(''); @@ -87,7 +92,7 @@ export const OrganizationPopover: React.FC = ({ onClose={onClose} onSelect={onSelect} anchorEl={anchorEl} - selectedId={selected.id} + selectedId={communitySelected ? undefined : selected.id} items={items || []} isLoading={organizationsLoadable.isFetching} hasNextPage={organizationsLoadable.hasNextPage ?? false} @@ -99,6 +104,15 @@ export const OrganizationPopover: React.FC = ({ onSearchChange={handleSearchChange} onAddNew={canCreateOrganizations ? onAddNew : undefined} addNewTooltip={t('organizations_add_new')} + footerAction={ + onCommunityNavigate + ? { + content: , + onClick: onCommunityNavigate, + dataCy: 'organization-switch-community', + } + : undefined + } /> ); }; diff --git a/webapp/src/component/organizationSwitch/OrganizationSwitch.tsx b/webapp/src/component/organizationSwitch/OrganizationSwitch.tsx index ee0ccdd59d0..d4a420f9f4c 100644 --- a/webapp/src/component/organizationSwitch/OrganizationSwitch.tsx +++ b/webapp/src/component/organizationSwitch/OrganizationSwitch.tsx @@ -4,6 +4,7 @@ import { ArrowDropDown } from 'tg.component/CustomIcons'; import { components } from 'tg.service/apiSchema.generated'; import { OrganizationItem } from './OrganizationItem'; +import { CommunityTranslationItem } from './CommunityTranslationItem'; import { useHistory } from 'react-router-dom'; import { LINKS } from 'tg.constants/links'; import { usePreferredOrganization } from 'tg.globalContext/helpers'; @@ -11,23 +12,36 @@ import { OrganizationPopover } from './OrganizationPopover'; type OrganizationModel = components['schemas']['OrganizationModel']; -const StyledLink = styled(Link)` +export type SwitchSurface = 'organization' | 'community'; + +const StyledLink = styled(Link, { + shouldForwardProp: (prop) => prop !== 'plain', +})<{ plain?: boolean }>` display: flex; + align-items: center; + flex-wrap: wrap; + flex-shrink: 1; + cursor: pointer; + ${({ plain, theme }) => plain && `color: ${theme.palette.text.primary};`} `; type Props = { onSelect?: (organization: OrganizationModel) => void; ownedOnly?: boolean; + selectedSurface?: SwitchSurface; + plain?: boolean; }; export const OrganizationSwitch: React.FC = ({ onSelect, ownedOnly, + selectedSurface = 'organization', + plain, }) => { const anchorEl = useRef(null); const [isOpen, setIsOpen] = useState(false); - const { preferredOrganization } = usePreferredOrganization(); - const { updatePreferredOrganization } = usePreferredOrganization(); + const { preferredOrganization, updatePreferredOrganization } = + usePreferredOrganization(); const history = useHistory(); const handleClose = () => { @@ -49,40 +63,36 @@ export const OrganizationSwitch: React.FC = ({ history.push(LINKS.ORGANIZATIONS_ADD.build()); }; + const handleCommunityNavigate = () => { + history.push(LINKS.COMMUNITY_PROJECTS.build()); + }; + + const isCommunitySurface = selectedSurface === 'community'; + + const switchLabel = isCommunitySurface ? ( + + ) : preferredOrganization ? ( + + ) : null; + return ( - <> - - - {preferredOrganization && ( - - )} - - + + + {switchLabel} + + - - - + + ); }; diff --git a/webapp/src/constants/links.tsx b/webapp/src/constants/links.tsx index 49325c29bca..8774b7de695 100644 --- a/webapp/src/constants/links.tsx +++ b/webapp/src/constants/links.tsx @@ -297,6 +297,8 @@ export class LINKS { static PUBLIC_PROJECTS = Link.ofRoot('public-projects'); + static COMMUNITY_PROJECTS = Link.ofRoot('community-projects'); + /** * Visible with view permissions */ diff --git a/webapp/src/views/projects/CommunityProjectsView.tsx b/webapp/src/views/projects/CommunityProjectsView.tsx new file mode 100644 index 00000000000..ce6f309348a --- /dev/null +++ b/webapp/src/views/projects/CommunityProjectsView.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import { T, useTranslate } from '@tolgee/react'; +import { useHistory } from 'react-router-dom'; + +import { EmailNotVerifiedView } from 'tg.component/EmailNotVerifiedView'; +import { EmptyListMessage } from 'tg.component/common/EmptyListMessage'; +import { BaseView } from 'tg.component/layout/BaseView'; +import { DashboardPage } from 'tg.component/layout/DashboardPage'; +import { OrganizationSwitch } from 'tg.component/organizationSwitch/OrganizationSwitch'; +import { LINKS } from 'tg.constants/links'; +import { useIsEmailVerified } from 'tg.globalContext/helpers'; +import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { ProjectsList } from 'tg.views/projects/ProjectsList'; +import { useLatchedSearchVisibility } from 'tg.views/projects/useLatchedSearchVisibility'; +import { CriticalUsageCircle } from 'tg.ee'; + +const CommunityProjects = () => { + const [page, setPage] = useState(0); + const [search, setSearch] = useState(''); + const { t } = useTranslate(); + const history = useHistory(); + + const loadable = useApiQuery({ + url: '/v2/public/projects/with-stats', + method: 'get', + query: { + page, + size: 20, + search, + sort: ['name,asc'], + }, + options: { + keepPreviousData: true, + }, + }); + + const showSearch = useLatchedSearchVisibility( + loadable.data?.page?.totalElements, + search + ); + + return ( + + history.push(LINKS.ROOT.build())} + /> + } + onSearch={ + showSearch + ? (value) => { + setSearch(value); + setPage(0); + } + : undefined + } + searchPlaceholder={t('projects_search_placeholder')} + maxWidth={1000} + allCentered + hideChildrenOnLoading={false} + customButtons={[]} + loading={loadable.isFetching} + > + + + + } + /> + + + ); +}; + +export const CommunityProjectsView = () => { + const isEmailVerified = useIsEmailVerified(); + return isEmailVerified ? : ; +}; diff --git a/webapp/src/views/projects/ProjectListView.tsx b/webapp/src/views/projects/ProjectListView.tsx index 368c3b4faa3..b4dfcbf6f82 100644 --- a/webapp/src/views/projects/ProjectListView.tsx +++ b/webapp/src/views/projects/ProjectListView.tsx @@ -16,6 +16,7 @@ import { usePreferredOrganization, } from 'tg.globalContext/helpers'; import { OrganizationSwitch } from 'tg.component/organizationSwitch/OrganizationSwitch'; +import { useLatchedSearchVisibility } from 'tg.views/projects/useLatchedSearchVisibility'; import { QuickStartHighlight } from 'tg.component/layout/QuickStartGuide/QuickStartHighlight'; import { CriticalUsageCircle } from 'tg.ee'; @@ -51,13 +52,18 @@ export const ProjectListView = () => { const addAllowed = isOrganizationOwnerOrMaintainer || isAdminAccess; - const showSearch = - search || (listPermitted.data?.page?.totalElements ?? 0) > 5; + const showSearch = useLatchedSearchVisibility( + listPermitted.data?.page?.totalElements, + search + ); return ( } + standaloneTitle onSearch={showSearch ? setSearch : undefined} searchPlaceholder={t('projects_search_placeholder')} maxWidth={1000} @@ -74,11 +80,7 @@ export const ProjectListView = () => { } addLabel={t('projects_add_button')} hideChildrenOnLoading={false} - navigation={[ - [], - [t('projects_title'), LINKS.PROJECTS.build()], - ]} - navigationRight={} + customButtons={[]} loading={listPermitted.isFetching} > { + const relevant = + Boolean(search) || (totalElements ?? 0) > MAX_PROJECTS_WITHOUT_SEARCH; + const [latched, setLatched] = useState(false); + useEffect(() => { + if (relevant) { + setLatched(true); + } + }, [relevant]); + // `relevant` turns the field on synchronously (no first-paint flash); `latched` keeps it on + // when `relevant` transiently goes false during a keepPreviousData refetch. + return relevant || latched; +}; From fb42776c48b1d2434c84fa1f38706e280c3043c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Thu, 2 Jul 2026 14:36:46 +0200 Subject: [PATCH 2/7] fix: disable community projects button --- .../organizationSwitch/OrganizationSwitch.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/webapp/src/component/organizationSwitch/OrganizationSwitch.tsx b/webapp/src/component/organizationSwitch/OrganizationSwitch.tsx index d4a420f9f4c..2c5a89b98fc 100644 --- a/webapp/src/component/organizationSwitch/OrganizationSwitch.tsx +++ b/webapp/src/component/organizationSwitch/OrganizationSwitch.tsx @@ -63,9 +63,12 @@ export const OrganizationSwitch: React.FC = ({ history.push(LINKS.ORGANIZATIONS_ADD.build()); }; - const handleCommunityNavigate = () => { - history.push(LINKS.COMMUNITY_PROJECTS.build()); - }; + // TODO: fully implemented, but after talk with designer we decided to + // show the community only when user contributed to a public project + // which needs contributors tracking - will be implemented in future pitch + // const handleCommunityNavigate = () => { + // history.push(LINKS.COMMUNITY_PROJECTS.build()); + // }; const isCommunitySurface = selectedSurface === 'community'; @@ -91,7 +94,7 @@ export const OrganizationSwitch: React.FC = ({ anchorEl={anchorEl.current!} onAddNew={handleCreateNewOrg} communitySelected={isCommunitySurface} - onCommunityNavigate={handleCommunityNavigate} + // onCommunityNavigate={handleCommunityNavigate} /> ); From 6c877ce088ebad3efee499eb92614f451797a8ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Thu, 2 Jul 2026 14:58:31 +0200 Subject: [PATCH 3/7] feat: show community translation banner on community projects page Render CommunityTranslationBanner at the top of CommunityProjectsView (above the header), matching the logged-out public projects view. --- e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts | 3 ++- webapp/src/views/projects/CommunityProjectsView.tsx | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts b/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts index a39ea36b36c..e24945f797b 100644 --- a/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts +++ b/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts @@ -62,9 +62,10 @@ describe('Community projects navigation', () => { cy.location('pathname').should('eq', '/community-projects'); }); - it('shows the community chrome: community chip, no add button', () => { + it('shows the community chrome: banner, community chip, no add button', () => { visitCommunity(); gcy('community-projects-view').should('be.visible'); + gcy('community-translation-banner').should('be.visible'); gcy('organization-switch') .findDcy('community-translation-item') .should('be.visible'); diff --git a/webapp/src/views/projects/CommunityProjectsView.tsx b/webapp/src/views/projects/CommunityProjectsView.tsx index ce6f309348a..b8412b2d6e6 100644 --- a/webapp/src/views/projects/CommunityProjectsView.tsx +++ b/webapp/src/views/projects/CommunityProjectsView.tsx @@ -11,6 +11,7 @@ import { LINKS } from 'tg.constants/links'; import { useIsEmailVerified } from 'tg.globalContext/helpers'; import { useApiQuery } from 'tg.service/http/useQueryApi'; import { ProjectsList } from 'tg.views/projects/ProjectsList'; +import { CommunityTranslationBanner } from 'tg.views/projects/public/CommunityTranslationBanner'; import { useLatchedSearchVisibility } from 'tg.views/projects/useLatchedSearchVisibility'; import { CriticalUsageCircle } from 'tg.ee'; @@ -41,6 +42,7 @@ const CommunityProjects = () => { return ( + Date: Thu, 2 Jul 2026 15:09:11 +0200 Subject: [PATCH 4/7] test: skip community switcher-entry e2e tests while the button is disabled The "Community translation" switcher entry is disabled until contributor tracking lands (future pitch); skip the tests that drive it, with a TODO to re-enable them when the button returns. --- .../projects/communityProjectsNavigation.cy.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts b/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts index e24945f797b..fa726fd61c0 100644 --- a/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts +++ b/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts @@ -50,13 +50,16 @@ describe('Community projects navigation', () => { cy.waitForDom(); }; - it('navigates to the community page via the dropdown entry (mouse)', () => { + // TODO: the "Community translation" switcher entry is currently disabled (it will return once + // contributor tracking lands in a future pitch). Re-enable these five skipped tests — the ones + // that drive `organization-switch-community` — when the button is added back. + it.skip('navigates to the community page via the dropdown entry (mouse)', () => { openSwitch(); gcy('organization-switch-community').click(); cy.location('pathname').should('eq', '/community-projects'); }); - it('navigates to the community page via the dropdown entry (keyboard)', () => { + it.skip('navigates to the community page via the dropdown entry (keyboard)', () => { openSwitch(); gcy('organization-switch-community').focus().type('{enter}'); cy.location('pathname').should('eq', '/community-projects'); @@ -102,7 +105,8 @@ describe('Community projects navigation', () => { .should('be.visible'); }); - it('highlights no org row while on the community page but still offers the community entry', () => { + // Skipped: community switcher entry disabled — re-enable with the button (see TODO above). + it.skip('highlights no org row while on the community page but still offers the community entry', () => { visitCommunity(); openSwitch(); gcy('switch-popover-item').should('exist'); @@ -110,7 +114,8 @@ describe('Community projects navigation', () => { gcy('organization-switch-community').should('be.visible'); }); - it('offers the community entry from a switcher outside the projects pages', () => { + // Skipped: community switcher entry disabled — re-enable with the button (see TODO above). + it.skip('offers the community entry from a switcher outside the projects pages', () => { cy.visit( `${HOST}/organizations/${organizationData['Tolgee'].slug}/members` ); @@ -129,7 +134,8 @@ describe('Community projects navigation', () => { gcy('global-list-search').should('not.exist'); }); - it('closes the popover on Escape from the footer entry', () => { + // Skipped: community switcher entry disabled — re-enable with the button (see TODO above). + it.skip('closes the popover on Escape from the footer entry', () => { openSwitch(); gcy('organization-switch-community').focus().type('{esc}'); gcy('switch-popover-item').should('not.exist'); From 1a7adf61bc0da268b820f057d5b99c6616cc964d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Thu, 2 Jul 2026 15:09:11 +0200 Subject: [PATCH 5/7] feat: drop controls column from public project-list row Public variant of DashboardProjectListItem uses a controls-less grid at every breakpoint (no reserved space for the hidden row actions), and the default layout widens the title column 150px -> 180px. --- .../projects/DashboardProjectListItem.tsx | 46 +++++++++++-------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/webapp/src/views/projects/DashboardProjectListItem.tsx b/webapp/src/views/projects/DashboardProjectListItem.tsx index bae93781ff2..9ff296c14ee 100644 --- a/webapp/src/views/projects/DashboardProjectListItem.tsx +++ b/webapp/src/views/projects/DashboardProjectListItem.tsx @@ -24,10 +24,18 @@ import { CircledLanguageIconList } from 'tg.component/languages/CircledLanguageI import { TransparentChip } from 'tg.component/common/chips/TransparentChip'; import { QaBadge } from 'tg.ee'; -const StyledContainer = styled('div')` +const StyledContainer = styled('div', { + shouldForwardProp: (prop) => prop !== 'isPublicVariant', +})<{ isPublicVariant?: boolean }>` display: grid; - grid-template-columns: calc(${({ theme }) => theme.spacing(2)} + 50px) 150px 100px 5fr 1.5fr 70px; - grid-template-areas: 'image title keyCount stats languages controls'; + grid-template-columns: ${({ theme, isPublicVariant }) => + isPublicVariant + ? `calc(${theme.spacing(2)} + 50px) 180px 100px 5fr 1.5fr` + : `calc(${theme.spacing(2)} + 50px) 150px 100px 5fr 1.5fr 70px`}; + grid-template-areas: ${({ isPublicVariant }) => + isPublicVariant + ? "'image title keyCount stats languages'" + : "'image title keyCount stats languages controls'"}; padding: ${({ theme }) => theme.spacing(3, 2.5)}; cursor: pointer; background-color: ${({ theme }) => theme.palette.background.default}; @@ -43,20 +51,21 @@ const StyledContainer = styled('div')` } } @container (max-width: 850px) { - grid-template-columns: auto 1fr 1fr 70px; - grid-template-areas: - 'image title keyCount controls' - 'image title languages controls' - 'image stats stats stats'; + grid-template-columns: ${({ isPublicVariant }) => + isPublicVariant ? 'auto 1fr 1fr' : 'auto 1fr 1fr 70px'}; + grid-template-areas: ${({ isPublicVariant }) => + isPublicVariant + ? "'image title keyCount' 'image title languages' 'image stats stats'" + : "'image title keyCount controls' 'image title languages controls' 'image stats stats stats'"}; } @container (max-width: 599px) { grid-gap: ${({ theme }) => theme.spacing(1, 2)}; - grid-template-columns: auto 1fr 70px; - grid-template-areas: - 'image title controls' - 'image keyCount controls' - 'languages languages languages' - 'stats stats stats'; + grid-template-columns: ${({ isPublicVariant }) => + isPublicVariant ? 'auto 1fr' : 'auto 1fr 70px'}; + grid-template-areas: ${({ isPublicVariant }) => + isPublicVariant + ? "'image title' 'image keyCount' 'languages languages' 'stats stats'" + : "'image title controls' 'image keyCount controls' 'languages languages languages' 'stats stats stats'"}; } `; @@ -165,6 +174,7 @@ const DashboardProjectListItem = ({ variant = 'default', ...p }: Props) => { const content = ( history.push( isPublicVariant && !allowPrivate @@ -222,8 +232,8 @@ const DashboardProjectListItem = ({ variant = 'default', ...p }: Props) => { - - {!isPublicVariant && ( + {!isPublicVariant && ( + {showQaBadge ? ( @@ -265,8 +275,8 @@ const DashboardProjectListItem = ({ variant = 'default', ...p }: Props) => { projectName={p.name} /> - )} - + + )} ); From 410cf0f6e5617edd9a1c37f9df60b4b9aa983e80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Thu, 2 Jul 2026 15:18:45 +0200 Subject: [PATCH 6/7] fix: community banner stretched to half the page DashboardPage's main area and BaseView's content are CSS grids whose auto rows stretch to fill the viewport, so the banner (a second grid row) was blown up vertically. Wrap the banner + list in a flex column so the banner keeps its natural content height. --- .../views/projects/CommunityProjectsView.tsx | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/webapp/src/views/projects/CommunityProjectsView.tsx b/webapp/src/views/projects/CommunityProjectsView.tsx index b8412b2d6e6..9565751a38d 100644 --- a/webapp/src/views/projects/CommunityProjectsView.tsx +++ b/webapp/src/views/projects/CommunityProjectsView.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { styled } from '@mui/material'; import { T, useTranslate } from '@tolgee/react'; import { useHistory } from 'react-router-dom'; @@ -15,6 +16,14 @@ import { CommunityTranslationBanner } from 'tg.views/projects/public/CommunityTr import { useLatchedSearchVisibility } from 'tg.views/projects/useLatchedSearchVisibility'; import { CriticalUsageCircle } from 'tg.ee'; +// Flex column so the full-width banner keeps its content height — the surrounding grid layouts +// (DashboardPage main + BaseView content) stretch a bare second grid row to fill the viewport. +const StyledContent = styled('div')` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(3)}; +`; + const CommunityProjects = () => { const [page, setPage] = useState(0); const [search, setSearch] = useState(''); @@ -42,7 +51,6 @@ const CommunityProjects = () => { return ( - { customButtons={[]} loading={loadable.isFetching} > - - - - } - /> + + + + + + } + /> + ); From 4c9add9812116afceae9e22259fd91626c59b0a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Kuchy=C5=88ka=20=28Anty=29?= Date: Thu, 2 Jul 2026 17:13:19 +0200 Subject: [PATCH 7/7] test: update admin projects e2e to redesigned base-view title --- e2e/cypress/e2e/administration/base.cy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/cypress/e2e/administration/base.cy.ts b/e2e/cypress/e2e/administration/base.cy.ts index 6c3cedf1c5c..7d356028a49 100644 --- a/e2e/cypress/e2e/administration/base.cy.ts +++ b/e2e/cypress/e2e/administration/base.cy.ts @@ -48,7 +48,7 @@ describe('Administration', () => { .findDcy('administration-organizations-projects-button') .click(); assertAdminFrameVisible(); - gcy('navigation-item').contains('Projects'); + gcy('global-base-view-title').contains('Projects'); }); it('can access organization settings', () => {