Skip to content
Open
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
218 changes: 218 additions & 0 deletions cypress/component/GenericHeader.cy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import React from 'react';
import GenericHeader from '../../src/Components/GenericHeader/GenericHeader';
import { MemoryRouter } from 'react-router-dom';
import { DashboardTemplate } from '../../src/api/dashboard-templates';

const mockDashboard: DashboardTemplate = {
id: 1,
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
deletedAt: null,
userIdentityID: 1,
default: false,
templateBase: { name: 'test', displayName: 'Test' },
templateConfig: { sm: [], md: [], lg: [], xl: [] },
dashboardName: 'My Dashboard',
};

describe('GenericHeader', () => {
describe('without dashboard', () => {
it('renders "Add widgets" button', () => {
cy.mount(
<MemoryRouter>
<GenericHeader onRenameDashboard={cy.stub()} />
</MemoryRouter>
);
cy.get('[data-ouia-component-id="add-widget-button"]').should('be.visible').and('contain.text', 'Add widgets');
});

it('does not render edit name button when no dashboard', () => {
cy.mount(
<MemoryRouter>
<GenericHeader onRenameDashboard={cy.stub()} />
</MemoryRouter>
);
cy.get('button[aria-label="Edit dashboard name"]').should('not.exist');
});

it('does not render kebab dropdown when no dashboard', () => {
cy.mount(
<MemoryRouter>
<GenericHeader onRenameDashboard={cy.stub()} />
</MemoryRouter>
);
cy.get('button[aria-label="kebab dropdown toggle"]').should('not.exist');
});
});

describe('with dashboard', () => {
const mountHeader = (onRenameDashboard = cy.stub().resolves()) => {
cy.mount(
<MemoryRouter>
<GenericHeader dashboard={mockDashboard} onRenameDashboard={onRenameDashboard} />
</MemoryRouter>
);
};

it('renders the dashboard name', () => {
mountHeader();
cy.contains('h1', 'My Dashboard').should('be.visible');
});

it('renders "Add widgets" button', () => {
mountHeader();
cy.get('[data-ouia-component-id="add-widget-button"]').should('be.visible');
});

it('renders the edit name button', () => {
mountHeader();
cy.get('button[aria-label="Edit dashboard name"]').should('be.visible');
});

it('renders the kebab dropdown', () => {
mountHeader();
cy.get('button[aria-label="kebab dropdown toggle"]').should('be.visible');
});

describe('inline editing', () => {
it('enters edit mode when pencil icon is clicked', () => {
mountHeader();
cy.get('button[aria-label="Edit dashboard name"]').click();
cy.get('input[aria-label="Dashboard name"]').should('be.visible').and('have.value', 'My Dashboard');
});

it('shows confirm and cancel buttons in edit mode', () => {
mountHeader();
cy.get('button[aria-label="Edit dashboard name"]').click();
cy.get('button[aria-label="Confirm name"]').should('be.visible');
cy.get('button[aria-label="Cancel editing"]').should('be.visible');
});

it('hides the dashboard name heading in edit mode', () => {
mountHeader();
cy.get('button[aria-label="Edit dashboard name"]').click();
cy.contains('h1', 'My Dashboard').should('not.exist');
});

it('calls onRenameDashboard with new name on confirm click', () => {
const onRenameDashboard = cy.stub().as('onRenameDashboard').resolves();
mountHeader(onRenameDashboard);

cy.get('button[aria-label="Edit dashboard name"]').click();
cy.get('input[aria-label="Dashboard name"]').clear().type('Renamed Dashboard');
cy.get('button[aria-label="Confirm name"]').click();

cy.get('@onRenameDashboard').should('have.been.calledOnceWith', 'Renamed Dashboard');
});

it('calls onRenameDashboard on Enter key', () => {
const onRenameDashboard = cy.stub().as('onRenameDashboard').resolves();
mountHeader(onRenameDashboard);

cy.get('button[aria-label="Edit dashboard name"]').click();
cy.get('input[aria-label="Dashboard name"]').clear().type('Enter Dashboard{enter}');

cy.get('@onRenameDashboard').should('have.been.calledOnceWith', 'Enter Dashboard');
});

it('cancels editing and restores original name on cancel click', () => {
mountHeader();

cy.get('button[aria-label="Edit dashboard name"]').click();
cy.get('input[aria-label="Dashboard name"]').clear().type('Something else');
cy.get('button[aria-label="Cancel editing"]').click();

cy.contains('h1', 'My Dashboard').should('be.visible');
cy.get('input[aria-label="Dashboard name"]').should('not.exist');
});

it('cancels editing on Escape key', () => {
mountHeader();

cy.get('button[aria-label="Edit dashboard name"]').click();
cy.get('input[aria-label="Dashboard name"]').clear().type('Something else{esc}');

cy.contains('h1', 'My Dashboard').should('be.visible');
cy.get('input[aria-label="Dashboard name"]').should('not.exist');
});

it('does not call onRenameDashboard when name is empty', () => {
const onRenameDashboard = cy.stub().as('onRenameDashboard').resolves();
mountHeader(onRenameDashboard);

cy.get('button[aria-label="Edit dashboard name"]').click();
cy.get('input[aria-label="Dashboard name"]').clear();
cy.get('button[aria-label="Confirm name"]').click();

cy.get('@onRenameDashboard').should('not.have.been.called');
});

it('trims whitespace from the new name', () => {
const onRenameDashboard = cy.stub().as('onRenameDashboard').resolves();
mountHeader(onRenameDashboard);

cy.get('button[aria-label="Edit dashboard name"]').click();
cy.get('input[aria-label="Dashboard name"]').clear().type(' Trimmed Name ');
cy.get('button[aria-label="Confirm name"]').click();

cy.get('@onRenameDashboard').should('have.been.calledOnceWith', 'Trimmed Name');
});

it('exits edit mode after successful rename', () => {
const onRenameDashboard = cy.stub().as('onRenameDashboard').resolves();
mountHeader(onRenameDashboard);

cy.get('button[aria-label="Edit dashboard name"]').click();
cy.get('input[aria-label="Dashboard name"]').clear().type('New Name');
cy.get('button[aria-label="Confirm name"]').click();

cy.get('input[aria-label="Dashboard name"]').should('not.exist');
cy.get('button[aria-label="Edit dashboard name"]').should('be.visible');
});
});

describe('kebab dropdown', () => {
it('opens the dropdown menu on toggle click', () => {
mountHeader();
cy.get('button[aria-label="kebab dropdown toggle"]').click();
cy.get('[role="menu"]').should('be.visible');
});

it('displays all menu items', () => {
mountHeader();
cy.get('button[aria-label="kebab dropdown toggle"]').click();

cy.contains('[role="menuitem"]', 'Set as homepage').should('be.visible');
cy.contains('[role="menuitem"]', 'Duplicate').should('be.visible');
cy.contains('[role="menuitem"]', 'Copy configuration string').should('be.visible');
cy.contains('[role="menuitem"]', 'Delete dashboard').should('be.visible');
});

it('opens delete modal when Delete dashboard is clicked', () => {
mountHeader();
cy.get('button[aria-label="kebab dropdown toggle"]').click();
cy.contains('[role="menuitem"]', 'Delete dashboard').click();

cy.get('.pf-v6-c-modal-box').should('be.visible');
});
});
});

describe('with default dashboard', () => {
const defaultDashboard: DashboardTemplate = {
...mockDashboard,
default: true,
};

it('has Set as homepage item disabled when dashboard is default', () => {
cy.mount(
<MemoryRouter>
<GenericHeader dashboard={defaultDashboard} onRenameDashboard={cy.stub()} />
</MemoryRouter>
);

cy.get('button[aria-label="kebab dropdown toggle"]').click();
cy.contains('[role="menuitem"]', 'Set as homepage').should('have.attr', 'aria-disabled', 'true');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,67 @@ test.describe('Set Dashboard as Homepage from Generic Page', () => {
expect(await hasHomeIcon(page, defaultName)).toBe(false);
});
});

test.describe('Inline Editing Dashboard Name', () => {
test.beforeEach(async ({ page }) => {
await disableCookiePrompt(page);
});

test('should rename a dashboard and see the new name on the generic page and in Dashboard Hub', async ({ page }) => {
await navigateToDashboardHub(page);

const { nonDefaultName } = await findDashboardNames(page);
if (!nonDefaultName) {
test.skip(true, 'No non-default dashboard found to test with');
return;
}

const newName = `Renamed ${Date.now()}`;

await navigateToGenericDashboard(page, nonDefaultName);

await page.getByRole('button', { name: 'Edit dashboard name' }).click();
const input = page.getByRole('textbox', { name: 'Dashboard name' });
await expect(input).toBeVisible();
await input.clear();
await input.fill(newName);
await page.getByRole('button', { name: 'Confirm name' }).click();

await expect(input).not.toBeVisible({ timeout: 5000 });
await expect(page.locator('h1').filter({ hasText: newName })).toBeVisible({ timeout: 5000 });

await navigateToDashboardHub(page);
await expect(page.getByRole('link', { name: newName })).toBeVisible({ timeout: 10000 });

// Restore the original name
await navigateToGenericDashboard(page, newName);
await page.getByRole('button', { name: 'Edit dashboard name' }).click();
const restoreInput = page.getByRole('textbox', { name: 'Dashboard name' });
await restoreInput.clear();
await restoreInput.fill(nonDefaultName);
await page.getByRole('button', { name: 'Confirm name' }).click();
await expect(restoreInput).not.toBeVisible({ timeout: 5000 });
});

test('should cancel editing and keep the original name', async ({ page }) => {
await navigateToDashboardHub(page);

const { nonDefaultName } = await findDashboardNames(page);
if (!nonDefaultName) {
test.skip(true, 'No non-default dashboard found to test with');
return;
}

await navigateToGenericDashboard(page, nonDefaultName);

await page.getByRole('button', { name: 'Edit dashboard name' }).click();
const input = page.getByRole('textbox', { name: 'Dashboard name' });
await expect(input).toBeVisible();
await input.clear();
await input.fill('Should Not Be Saved');
await page.getByRole('button', { name: 'Cancel editing' }).click();

await expect(input).not.toBeVisible({ timeout: 5000 });
await expect(page.locator('h1').filter({ hasText: nonDefaultName })).toBeVisible({ timeout: 5000 });
});
});
66 changes: 61 additions & 5 deletions src/Components/GenericHeader/GenericHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,80 @@
import '../Header/Header.scss';

import { ActionList, ActionListItem, Button } from '@patternfly/react-core';
import { ActionList, ActionListItem, Button, Flex, FlexItem, TextInput } from '@patternfly/react-core';
import PageHeader from '@patternfly/react-component-groups/dist/dynamic/PageHeader';
import React from 'react';
import { PlusCircleIcon } from '@patternfly/react-icons';
import React, { useState } from 'react';
import { CheckIcon, PencilAltIcon, PlusCircleIcon, TimesIcon } from '@patternfly/react-icons';
import { useSetAtom } from 'jotai';
import { drawerExpandedAtom } from '../../state/drawerExpandedAtom';
import { DashboardTemplate } from '../../api/dashboard-templates';
import GenericHeaderDropdown from './GenericHeaderDropdown';

interface GenericHeaderProps {
dashboard?: DashboardTemplate;
onRenameDashboard: (dashboardName: string) => Promise<unknown>;
}

const GenericHeader = ({ dashboard }: GenericHeaderProps) => {
const GenericHeader = ({ dashboard, onRenameDashboard }: GenericHeaderProps) => {
const toggleOpen = useSetAtom(drawerExpandedAtom);
const [isEditing, setIsEditing] = useState(false);
const [editedName, setEditedName] = useState(dashboard?.dashboardName ?? '');

const handleConfirm = async () => {
if (editedName.trim() && dashboard) {
await onRenameDashboard(editedName.trim());
}
setIsEditing(false);
};

const handleCancel = () => {
setEditedName(dashboard?.dashboardName ?? '');
setIsEditing(false);
};

const titleContent = isEditing ? (
<Flex spaceItems={{ default: 'spaceItemsSm' }} alignItems={{ default: 'alignItemsCenter' }}>
<FlexItem>
<TextInput
value={editedName}
onChange={(_event, value) => setEditedName(value)}
onKeyDown={(event) => {
if (event.key === 'Enter') handleConfirm();
if (event.key === 'Escape') handleCancel();
}}
aria-label="Dashboard name"
autoFocus
/>
</FlexItem>
<FlexItem>
<Button variant="plain" aria-label="Confirm name" onClick={handleConfirm} icon={<CheckIcon />} />
</FlexItem>
<FlexItem>
<Button variant="plain" aria-label="Cancel editing" onClick={handleCancel} icon={<TimesIcon />} />
</FlexItem>
</Flex>
) : (
<Flex spaceItems={{ default: 'spaceItemsSm' }} alignItems={{ default: 'alignItemsCenter' }}>
<FlexItem>{dashboard?.dashboardName}</FlexItem>
{dashboard && (
<FlexItem>
<Button
variant="plain"
aria-label="Edit dashboard name"
onClick={() => {
setEditedName(dashboard.dashboardName);
setIsEditing(true);
}}
icon={<PencilAltIcon />}
/>
</FlexItem>
)}
</Flex>
);

return (
<PageHeader
title={dashboard?.dashboardName}
ouiaId="Dashboard-hub-title-generic-page"
title={titleContent}
actionMenu={
<ActionList>
<ActionListItem>
Expand Down
4 changes: 2 additions & 2 deletions src/Modules/GenericDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import Portal from '@redhat-cloud-services/frontend-components-notifications/Por
const GenericDashboardPage = () => {
const { id } = useParams<{ id: string }>();
const isLayoutLocked = useAtomValue(lockedLayoutAtom);
const { template, saveTemplate, isLoaded, dashboard } = useDashboardTemplate(Number(id));
const { template, saveTemplate, renameDashboard, isLoaded, dashboard } = useDashboardTemplate(Number(id));
const resolveWidgetMapping = useSetAtom(resolvedWidgetMappingAtom);
const { visibilityFunctions } = useChrome();
const layoutRef = useRef<HTMLDivElement>(null);
Expand All @@ -32,7 +32,7 @@ const GenericDashboardPage = () => {
return (
<div className="genericDashboardPage">
<Portal notifications={notifications} removeNotification={removeNotification} />
<GenericHeader dashboard={dashboard} />
<GenericHeader dashboard={dashboard} onRenameDashboard={renameDashboard} />
<AddWidgetDrawer dismissible={false}>
<PageSection hasBodyWrapper={false} className="widg-c-page__main-section--grid 6-u-p-md-on-sm">
<GridLayout template={template} saveTemplate={saveTemplate} isLoaded={isLoaded} layoutRef={layoutRef} />
Expand Down
Loading
Loading