diff --git a/cypress/component/GenericHeader.cy.tsx b/cypress/component/GenericHeader.cy.tsx
new file mode 100644
index 0000000..9e9312b
--- /dev/null
+++ b/cypress/component/GenericHeader.cy.tsx
@@ -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(
+
+
+
+ );
+ 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(
+
+
+
+ );
+ cy.get('button[aria-label="Edit dashboard name"]').should('not.exist');
+ });
+
+ it('does not render kebab dropdown when no dashboard', () => {
+ cy.mount(
+
+
+
+ );
+ cy.get('button[aria-label="kebab dropdown toggle"]').should('not.exist');
+ });
+ });
+
+ describe('with dashboard', () => {
+ const mountHeader = (onRenameDashboard = cy.stub().resolves()) => {
+ cy.mount(
+
+
+
+ );
+ };
+
+ 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(
+
+
+
+ );
+
+ cy.get('button[aria-label="kebab dropdown toggle"]').click();
+ cy.contains('[role="menuitem"]', 'Set as homepage').should('have.attr', 'aria-disabled', 'true');
+ });
+ });
+});
diff --git a/playwright/set-as-homepage.spec.ts b/playwright/editing-dashboard.spec.ts
similarity index 66%
rename from playwright/set-as-homepage.spec.ts
rename to playwright/editing-dashboard.spec.ts
index 8919810..1b04171 100644
--- a/playwright/set-as-homepage.spec.ts
+++ b/playwright/editing-dashboard.spec.ts
@@ -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 });
+ });
+});
diff --git a/src/Components/GenericHeader/GenericHeader.tsx b/src/Components/GenericHeader/GenericHeader.tsx
index e26c9be..6faf3c6 100644
--- a/src/Components/GenericHeader/GenericHeader.tsx
+++ b/src/Components/GenericHeader/GenericHeader.tsx
@@ -1,9 +1,9 @@
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';
@@ -11,14 +11,70 @@ import GenericHeaderDropdown from './GenericHeaderDropdown';
interface GenericHeaderProps {
dashboard?: DashboardTemplate;
+ onRenameDashboard: (dashboardName: string) => Promise;
}
-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 ? (
+
+
+ setEditedName(value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') handleConfirm();
+ if (event.key === 'Escape') handleCancel();
+ }}
+ aria-label="Dashboard name"
+ autoFocus
+ />
+
+
+ } />
+
+
+ } />
+
+
+ ) : (
+
+ {dashboard?.dashboardName}
+ {dashboard && (
+
+
+ )}
+
+ );
return (
diff --git a/src/Modules/GenericDashboardPage.tsx b/src/Modules/GenericDashboardPage.tsx
index 58bffd8..b295285 100644
--- a/src/Modules/GenericDashboardPage.tsx
+++ b/src/Modules/GenericDashboardPage.tsx
@@ -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(null);
@@ -32,7 +32,7 @@ const GenericDashboardPage = () => {
return (
-
+
diff --git a/src/api/dashboard-templates.ts b/src/api/dashboard-templates.ts
index f2d6635..c5b2adb 100644
--- a/src/api/dashboard-templates.ts
+++ b/src/api/dashboard-templates.ts
@@ -279,6 +279,17 @@ export const copyDashboardTemplate = async (templateId: DashboardTemplate['id'],
return json;
};
+export const renameDashboardTemplate = async (templateId: DashboardTemplate['id'], data: { dashboardName: string }): Promise => {
+ const resp = await fetch(`/api/widget-layout/v1/${templateId}/rename`, {
+ method: 'PATCH',
+ headers: getRequestHeaders(),
+ body: JSON.stringify(data),
+ });
+ handleErrors(resp);
+ const json = await resp.json();
+ return json.data;
+};
+
export const getDefaultTemplate = (templates: DashboardTemplate[]): DashboardTemplate | undefined => {
return templates.find((itm) => itm.default === true);
};
diff --git a/src/hooks/useDashboardTemplate.ts b/src/hooks/useDashboardTemplate.ts
index 53b04c3..9a34e69 100644
--- a/src/hooks/useDashboardTemplate.ts
+++ b/src/hooks/useDashboardTemplate.ts
@@ -12,6 +12,8 @@ import {
patchDashboardTemplateHub,
widgetIdSeparator,
} from '../api/dashboard-templates';
+import { useSetAtom } from 'jotai';
+import { renameDashboardAtom } from '../state/dashboardsAtom';
const debouncedPatchDashboardTemplate = DebouncePromise(patchDashboardTemplateHub, 1500, {
onlyResolvesLast: true,
@@ -48,6 +50,7 @@ const useDashboardTemplate = (id: number) => {
const [isLoaded, setIsLoaded] = useState(false);
const [error, setError] = useState(null);
const [dashboard, setDashboard] = useState();
+ const renameDashboardInList = useSetAtom(renameDashboardAtom);
// widget mapping
useEffect(() => {
@@ -72,6 +75,14 @@ const useDashboardTemplate = (id: number) => {
fetchTemplate();
}, [id]);
+ const renameDashboard = useCallback(
+ async (dashboardName: string) => {
+ await renameDashboardInList({ id, dashboardName });
+ setDashboard((prev) => (prev ? { ...prev, dashboardName } : prev));
+ },
+ [id, renameDashboardInList]
+ );
+
const saveTemplate = useCallback(
async (newTemplate: ExtendedTemplateConfig) => {
setTemplate(newTemplate);
@@ -99,7 +110,7 @@ const useDashboardTemplate = (id: number) => {
[id]
);
- return { template, saveTemplate, isLoaded, dashboard, error };
+ return { template, saveTemplate, renameDashboard, isLoaded, dashboard, error };
};
export default useDashboardTemplate;
diff --git a/src/state/dashboardsAtom.ts b/src/state/dashboardsAtom.ts
index b09b6b6..9ddaa3b 100644
--- a/src/state/dashboardsAtom.ts
+++ b/src/state/dashboardsAtom.ts
@@ -1,5 +1,11 @@
import { atom } from 'jotai';
-import { DashboardTemplate, deleteDashboardTemplateFromHub, getUsersDashboards, setDefaultTemplate } from '../api/dashboard-templates';
+import {
+ DashboardTemplate,
+ deleteDashboardTemplateFromHub,
+ getUsersDashboards,
+ renameDashboardTemplate,
+ setDefaultTemplate,
+} from '../api/dashboard-templates';
export const dashboardsAtom = atom([]);
@@ -9,6 +15,13 @@ export const deleteDashboardAtom = atom(null, async (_get, set, id: DashboardTem
set(dashboardsAtom, dashboards);
});
+export const renameDashboardAtom = atom(null, async (_get, set, { id, dashboardName }: { id: DashboardTemplate['id']; dashboardName: string }) => {
+ const updated = await renameDashboardTemplate(id, { dashboardName });
+ const dashboards = await getUsersDashboards();
+ set(dashboardsAtom, dashboards);
+ return updated;
+});
+
export const setDefaultDashboardAtom = atom(null, async (_get, set, id: DashboardTemplate['id']) => {
await setDefaultTemplate(id);
const dashboards = await getUsersDashboards();