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', () => { diff --git a/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts b/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts new file mode 100644 index 00000000000..fa726fd61c0 --- /dev/null +++ b/e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts @@ -0,0 +1,248 @@ +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(); + }; + + // 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.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'); + }); + + 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'); + 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'); + }); + + // 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'); + gcy('switch-popover-item').filter('.Mui-selected').should('not.exist'); + gcy('organization-switch-community').should('be.visible'); + }); + + // 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` + ); + 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'); + }); + + // 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'); + 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..2c5a89b98fc 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,39 @@ export const OrganizationSwitch: React.FC = ({ history.push(LINKS.ORGANIZATIONS_ADD.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'; + + 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..9565751a38d --- /dev/null +++ b/webapp/src/views/projects/CommunityProjectsView.tsx @@ -0,0 +1,102 @@ +import { useState } from 'react'; +import { styled } from '@mui/material'; +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 { CommunityTranslationBanner } from 'tg.views/projects/public/CommunityTranslationBanner'; +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(''); + 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/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} /> - )} - + + )} ); 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; +};