Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion e2e/cypress/e2e/administration/base.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
248 changes: 248 additions & 0 deletions e2e/cypress/e2e/projects/communityProjectsNavigation.cy.ts
Original file line number Diff line number Diff line change
@@ -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<string, { slug: string }>;

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');
});
});
3 changes: 3 additions & 0 deletions e2e/cypress/support/dataCyType.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions webapp/src/component/RootRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -110,6 +111,9 @@ export const RootRouter = () => {
<PrivateRoute path={LINKS.PROJECTS.template}>
<ProjectsRouter />
</PrivateRoute>
<PrivateRoute exact path={LINKS.COMMUNITY_PROJECTS.template}>
<CommunityProjectsView />
</PrivateRoute>
<PrivateRoute path={`${LINKS.ORGANIZATIONS.template}`}>
<OrganizationsRouter />
</PrivateRoute>
Expand Down
27 changes: 26 additions & 1 deletion webapp/src/component/SwitchPopover/SwitchPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
styled,
Typography,
Button,
Divider,
} from '@mui/material';
import { Plus } from '@untitled-ui/icons-react';
import { useTranslate } from '@tolgee/react';
Expand Down Expand Up @@ -65,7 +66,7 @@ type SwitchPopoverProps<T extends { id: number; name: string }> = {
onClose: () => void;
onSelect: (item: T) => void;
anchorEl: HTMLElement;
selectedId: number;
selectedId?: number;

// Data
items: T[];
Expand All @@ -84,6 +85,12 @@ type SwitchPopoverProps<T extends { id: number; name: string }> = {
onAddNew?: () => void;
addNewTooltip?: string;

footerAction?: {
content: React.ReactNode;
onClick: () => void;
dataCy?: string;
};

// Search callback for a parent to handle
onSearchChange: (search: string) => void;
};
Expand All @@ -105,6 +112,7 @@ export function SwitchPopover<T extends { id: number; name: string }>({
searchThreshold = DEFAULT_SEARCH_THRESHOLD,
onAddNew,
addNewTooltip,
footerAction,
onSearchChange,
}: SwitchPopoverProps<T>) {
const [inputValue, setInputValue] = useState('');
Expand Down Expand Up @@ -222,6 +230,23 @@ export function SwitchPopover<T extends { id: number; name: string }>({
</StyledInputWrapper>
)}
/>
{footerAction && (
<>
<Divider />
<Box sx={{ py: 1 }}>
<MenuItem
tabIndex={0}
data-cy={footerAction.dataCy}
onClick={() => {
footerAction.onClick();
onClose();
}}
>
{footerAction.content}
</MenuItem>
</Box>
</>
)}
</StyledWrapper>
</Popover>
);
Expand Down
2 changes: 1 addition & 1 deletion webapp/src/component/layout/BaseView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export const BaseView: FC<BaseViewProps> = (props) => {
</Box>
</SecondaryBar>
)}
<HeaderBar noBorder={Boolean(displayNavigation)} {...props} />
<HeaderBar {...props} noBorder={Boolean(displayNavigation)} />
<StyledPaddingWrapper
{...props.wrapperProps}
gridRow={props.stretch ? 4 : undefined}
Expand Down
Loading
Loading