From 2572cef0af2986953fac8b01e1ef87efa6a8aeff Mon Sep 17 00:00:00 2001 From: PeterYurkovich Date: Thu, 10 Sep 2026 15:48:33 -0400 Subject: [PATCH] fix: lazy load perses permissions --- .../perses/99.coo_rbac_perses_user1.cy.ts | 20 +- .../perses/99.coo_rbac_perses_user2.cy.ts | 26 +- .../perses-dashboards-create-dashboard.ts | 19 +- .../perses-dashboards-list-dashboards.ts | 22 +- web/locales/en/plugin__monitoring-plugin.json | 18 +- .../perses/dashboard-action-modals.tsx | 164 ++++++----- .../perses/dashboard-actions-menu.tsx | 19 +- .../perses/dashboard-create-dialog.tsx | 134 +++++---- .../perses/dashboard-dialog-helpers.tsx | 106 +++---- .../dashboards/perses/dashboard-frame.tsx | 10 +- .../perses/dashboard-import-dialog.tsx | 197 ++++++------- .../perses/dashboard-list-frame.tsx | 10 +- .../dashboards/perses/dashboard-list.tsx | 85 +++--- .../dashboards/perses/dashboard-page.tsx | 7 - .../dashboards/perses/dashboard-toolbar.tsx | 41 +-- .../perses/hooks/useAccessReview.ts | 24 ++ .../perses/hooks/useActiveProject.tsx | 50 ++++ .../perses/hooks/useDashboardsData.ts | 25 +- .../perses/hooks/useEditableProjects.ts | 111 -------- .../dashboards/perses/hooks/usePerses.ts | 40 +-- .../perses/hooks/usePersesDashboardAccess.ts | 18 ++ .../dashboards/perses/perses-client.ts | 67 +---- .../perses/perses/datasource-client.ts | 4 +- .../perses/perses/global-datasource-client.ts | 4 +- .../dashboards/perses/project/ProjectBar.tsx | 37 --- .../perses/project/ProjectDropdown.tsx | 260 ------------------ .../perses/project/ProjectMenuToggle.tsx | 97 ------- .../perses/project/useActiveProject.tsx | 63 ----- .../dashboards/perses/project/utils.ts | 14 - web/src/components/data-test.ts | 3 + 30 files changed, 582 insertions(+), 1113 deletions(-) create mode 100644 web/src/components/dashboards/perses/hooks/useAccessReview.ts create mode 100644 web/src/components/dashboards/perses/hooks/useActiveProject.tsx delete mode 100644 web/src/components/dashboards/perses/hooks/useEditableProjects.ts create mode 100644 web/src/components/dashboards/perses/hooks/usePersesDashboardAccess.ts delete mode 100644 web/src/components/dashboards/perses/project/ProjectBar.tsx delete mode 100644 web/src/components/dashboards/perses/project/ProjectDropdown.tsx delete mode 100644 web/src/components/dashboards/perses/project/ProjectMenuToggle.tsx delete mode 100644 web/src/components/dashboards/perses/project/useActiveProject.tsx delete mode 100644 web/src/components/dashboards/perses/project/utils.ts diff --git a/web/cypress/support/perses/99.coo_rbac_perses_user1.cy.ts b/web/cypress/support/perses/99.coo_rbac_perses_user1.cy.ts index 5f19a6e49..c28fabba3 100644 --- a/web/cypress/support/perses/99.coo_rbac_perses_user1.cy.ts +++ b/web/cypress/support/perses/99.coo_rbac_perses_user1.cy.ts @@ -167,15 +167,19 @@ export function testCOORBACPersesTestsDevUser1(perspective: PerspectiveConfig) { }); - it(`4.${perspective.name} perspective - Create button validation - Disabled / Enabled`, () => { + it(`4.${perspective.name} perspective - Create button validation - Access denied / Enabled`, () => { cy.log(`4.1. use sidebar nav to go to Observe > Dashboards (Perses)`); listPersesDashboardsPage.shouldBeLoaded(); cy.log(`4.2 change namespace to observ-test`); cy.changeNamespace('observ-test'); - cy.log(`4.3. Verify Create button is disabled`); - listPersesDashboardsPage.assertCreateButtonIsDisabled(); + cy.log(`4.3. Verify Create button is enabled and creation is denied`); + listPersesDashboardsPage.assertCreateButtonIsEnabled(); + listPersesDashboardsPage.clickCreateButton(); + persesCreateDashboardsPage.createDashboardShouldBeLoaded(); + persesCreateDashboardsPage.assertCreateAccessDenied('observ-test'); + persesCreateDashboardsPage.createDashboardDialogCancelButton(); cy.log(`4.4 change namespace to openshift-cluster-observability-operator`); cy.changeNamespace('openshift-cluster-observability-operator'); @@ -294,9 +298,9 @@ export function testCOORBACPersesTestsDevUser1(perspective: PerspectiveConfig) { cy.log(`6.2. Change namespace to observ-test`); cy.changeNamespace('observ-test'); - cy.log(`6.3. Assert Kebab icon is disabled`); + cy.log(`6.3. Assert Rename/Delete row actions are disabled`); listPersesDashboardsPage.filter.byName(persesDashboardsDashboardDropdownPersesDev.PERSES_DASHBOARD_SAMPLE[0]); - listPersesDashboardsPage.assertKebabIconDisabled(); + listPersesDashboardsPage.assertKebabRowActionsDisabled(); cy.log(`6.4. Change namespace to openshift-cluster-observability-operator`); cy.changeNamespace('openshift-cluster-observability-operator'); @@ -317,7 +321,7 @@ export function testCOORBACPersesTestsDevUser1(perspective: PerspectiveConfig) { listPersesDashboardsPage.filter.byName(persesDashboardsDashboardDropdownPersesDev.PERSES_DASHBOARD_SAMPLE[0]); listPersesDashboardsPage.countDashboards('1'); listPersesDashboardsPage.clickKebabIcon(); - listPersesDashboardsPage.assertKebabIconDisabled(); + listPersesDashboardsPage.assertKebabRowActionsDisabled(); listPersesDashboardsPage.clearAllFilters(); cy.log(`6.4. Filter by Project and Name`); @@ -408,8 +412,8 @@ export function testCOORBACPersesTestsDevUser1(perspective: PerspectiveConfig) { cy.log(`8.5. Assert project dropdown options`); listPersesDashboardsPage.assertDuplicateProjectDropdownOptions('openshift-cluster-observability-operator', true); - listPersesDashboardsPage.assertDuplicateProjectDropdownOptions('observ-test', false); - listPersesDashboardsPage.assertDuplicateProjectDropdownOptions('perses-dev', false); + listPersesDashboardsPage.assertDuplicateProjectDropdownOptions('observ-test', true); + listPersesDashboardsPage.assertDuplicateProjectDropdownOptions('perses-dev', true); cy.log(`8.6. Enter new dashboard name`); listPersesDashboardsPage.duplicateDashboardEnterName(dashboardName); diff --git a/web/cypress/support/perses/99.coo_rbac_perses_user2.cy.ts b/web/cypress/support/perses/99.coo_rbac_perses_user2.cy.ts index 3c112d78d..27ac4224c 100644 --- a/web/cypress/support/perses/99.coo_rbac_perses_user2.cy.ts +++ b/web/cypress/support/perses/99.coo_rbac_perses_user2.cy.ts @@ -1,5 +1,6 @@ import { persesDashboardsPage } from '../../views/perses-dashboards'; import { listPersesDashboardsPage } from '../../views/perses-dashboards-list-dashboards'; +import { persesCreateDashboardsPage } from '../../views/perses-dashboards-create-dashboard'; import { persesDashboardsDashboardDropdownCOO, persesDashboardsDashboardDropdownPersesDev } from '../../fixtures/perses/constants'; export interface PerspectiveConfig { @@ -72,41 +73,46 @@ export function testCOORBACPersesTestsDevUser2(perspective: PerspectiveConfig) { }); - it(`3.${perspective.name} perspective - Create button validation - Disabled`, () => { + it(`3.${perspective.name} perspective - Create button validation - Access denied`, () => { cy.log(`3.1. use sidebar nav to go to Observe > Dashboards (Perses)`); listPersesDashboardsPage.shouldBeLoaded(); - cy.log(`3.2. Verify Create button is disabled`); - listPersesDashboardsPage.assertCreateButtonIsDisabled(); + cy.log(`3.2. Verify Create button is enabled but creation is denied`); + listPersesDashboardsPage.assertCreateButtonIsEnabled(); + listPersesDashboardsPage.clickCreateButton(); + persesCreateDashboardsPage.createDashboardShouldBeLoaded(); + persesCreateDashboardsPage.assertCreateAccessDenied('perses-dev'); + persesCreateDashboardsPage.createDashboardDialogCancelButton(); cy.log(`3.3 change namespace to perses-dev`); cy.changeNamespace('perses-dev'); - cy.log(`3.4. Verify Create button is disabled`); - listPersesDashboardsPage.assertCreateButtonIsDisabled(); + cy.log(`3.4. Verify Create button is enabled but creation is denied`); + listPersesDashboardsPage.assertCreateButtonIsEnabled(); }); - it(`4.${perspective.name} perspective - Kebab icon - Disabled`, () => { + it(`4.${perspective.name} perspective - Kebab icon - Row actions denied`, () => { cy.log(`4.1. use sidebar nav to go to Observe > Dashboards (Perses)`); listPersesDashboardsPage.shouldBeLoaded(); cy.log(`4.2. Change namespace to perses-dev`); cy.changeNamespace('perses-dev'); - cy.log(`4.3. Assert Kebab icon is disabled`); + cy.log(`4.3. Assert Rename/Delete row actions are disabled`); listPersesDashboardsPage.filter.byName(persesDashboardsDashboardDropdownPersesDev.PERSES_DASHBOARD_SAMPLE[0]); - listPersesDashboardsPage.assertKebabIconDisabled(); + listPersesDashboardsPage.assertKebabRowActionsDisabled(); + listPersesDashboardsPage.assertDuplicateAccessDenied('perses-dev'); listPersesDashboardsPage.clearAllFilters(); cy.log(`4.4. Change namespace to All Projects`); cy.changeNamespace('All Projects'); - cy.log(`4.5. Assert Kebab icon is disabled`); + cy.log(`4.5. Assert Rename/Delete row actions are disabled`); listPersesDashboardsPage.filter.byProject('perses-dev'); listPersesDashboardsPage.filter.byName(persesDashboardsDashboardDropdownPersesDev.PERSES_DASHBOARD_SAMPLE[0]); listPersesDashboardsPage.countDashboards('1'); - listPersesDashboardsPage.assertKebabIconDisabled(); + listPersesDashboardsPage.assertKebabRowActionsDisabled(); listPersesDashboardsPage.clearAllFilters(); }); diff --git a/web/cypress/views/perses-dashboards-create-dashboard.ts b/web/cypress/views/perses-dashboards-create-dashboard.ts index 252a3edb5..90744590e 100644 --- a/web/cypress/views/perses-dashboards-create-dashboard.ts +++ b/web/cypress/views/perses-dashboards-create-dashboard.ts @@ -1,4 +1,4 @@ -import { Classes, IDs } from "../../src/components/data-test"; +import { Classes, IDs, persesDashboardDataTestIDs } from "../../src/components/data-test"; import { persesCreateDashboard, persesDashboardsModalTitles } from "../fixtures/perses/constants"; export const persesCreateDashboardsPage = { @@ -39,6 +39,17 @@ export const persesCreateDashboardsPage = { cy.get(Classes.PersesCreateDashboardProjectDropdown).should('be.visible').click({ force: true }); }, + assertCreateAccessDenied: (project: string) => { + cy.log('persesCreateDashboardsPage.assertCreateAccessDenied'); + cy.get('#' + IDs.persesDashboardCreateDashboardName) + .should('be.visible') + .clear() + .type('access-denied-check'); + persesCreateDashboardsPage.selectProject(project); + cy.byTestID(persesDashboardDataTestIDs.createAccessDeniedHelperText).should('be.visible'); + cy.byPFRole('dialog').find('button').contains('Create').should('be.disabled'); + }, + enterDashboardName: (name: string) => { cy.log('persesCreateDashboardsPage.enterDashboardName'); cy.get('#' + IDs.persesDashboardCreateDashboardName).should('be.visible').clear().type(name); @@ -50,6 +61,12 @@ export const persesCreateDashboardsPage = { cy.wait(2000); }, + createDashboardDialogCancelButton: () => { + cy.log('persesCreateDashboardsPage.clickCancelButton'); + cy.byPFRole('dialog').find('button').contains('Cancel').should('be.visible').click({ force: true }); + cy.wait(2000); + }, + assertMaxLengthValidation: () => { cy.log('persesCreateDashboardsPage.assertMaxLengthValidation'); cy.byPFRole('dialog').find('h4').should('have.text', persesCreateDashboard.DIALOG_MAX_LENGTH_VALIDATION).should('be.visible'); diff --git a/web/cypress/views/perses-dashboards-list-dashboards.ts b/web/cypress/views/perses-dashboards-list-dashboards.ts index b16fe2948..bd93ed1f5 100644 --- a/web/cypress/views/perses-dashboards-list-dashboards.ts +++ b/web/cypress/views/perses-dashboards-list-dashboards.ts @@ -1,5 +1,5 @@ import { commonPages } from "./common"; -import { DataTestIDs, Classes, listPersesDashboardsOUIAIDs, listPersesDashboardsDataTestIDs, IDs, persesAriaLabels } from "../../src/components/data-test"; +import { DataTestIDs, Classes, listPersesDashboardsOUIAIDs, listPersesDashboardsDataTestIDs, IDs, persesAriaLabels, persesDashboardDataTestIDs } from "../../src/components/data-test"; import { listPersesDashboardsEmptyState, listPersesDashboardsPageSubtitle, persesDashboardsDuplicateDashboard, persesDashboardsRenameDashboard } from "../fixtures/perses/constants"; import { MonitoringPageTitles } from "../fixtures/monitoring/constants"; @@ -134,6 +134,15 @@ export const listPersesDashboardsPage = { cy.byAriaLabel(persesAriaLabels.persesDashboardKebabIcon).scrollIntoView().should('be.visible').should('have.attr', 'disabled'); }, + assertKebabRowActionsDisabled: (index?: number) => { + cy.log('persesDashboardsPage.assertKebabRowActionsDisabled'); + listPersesDashboardsPage.clickKebabIcon(index); + cy.byPFRole('menuitem').contains('Rename dashboard').should('have.attr', 'aria-disabled', 'true'); + cy.byPFRole('menuitem').contains('Delete dashboard').should('have.attr', 'aria-disabled', 'true'); + cy.byPFRole('menuitem').contains('Duplicate dashboard').should('not.have.attr', 'aria-disabled', 'true'); + listPersesDashboardsPage.clickKebabIcon(index); + }, + clickRenameDashboardOption: () => { cy.log('listPersesDashboardsPage.clickRenameDashboardOption'); cy.wait(1000); @@ -209,6 +218,17 @@ export const listPersesDashboardsPage = { cy.wait(2000); }, + assertDuplicateAccessDenied: (project: string) => { + cy.log('persesDashboardsPage.assertDuplicateAccessDenied'); + listPersesDashboardsPage.clickKebabIcon(); + listPersesDashboardsPage.clickDuplicateOption(); + listPersesDashboardsPage.duplicateDashboardEnterName('access-denied-check'); + listPersesDashboardsPage.duplicateDashboardSelectProjectDropdown(project); + cy.byTestID(persesDashboardDataTestIDs.createAccessDeniedHelperText).should('be.visible'); + cy.byPFRole('dialog').find('button').contains('Duplicate').should('be.disabled'); + listPersesDashboardsPage.duplicateDashboardCancelButton(); + }, + assertDuplicateProjectDropdownOptions: (project: string, contains: boolean) => { cy.log('listPersesDashboardsPage.assertDuplicateProjectDropdownOptions'); cy.get(Classes.PersesCreateDashboardProjectDropdown).should('be.visible').click({ force: true }); diff --git a/web/locales/en/plugin__monitoring-plugin.json b/web/locales/en/plugin__monitoring-plugin.json index 3ec361817..7b2b9addd 100644 --- a/web/locales/en/plugin__monitoring-plugin.json +++ b/web/locales/en/plugin__monitoring-plugin.json @@ -174,8 +174,6 @@ "Failed to create project \"{{project}}\". Please try again.": "Failed to create project \"{{project}}\". Please try again.", "Error creating project: {{error}}": "Error creating project: {{error}}", "Duplicate Dashboard": "Duplicate Dashboard", - "Loading...": "Loading...", - "Failed to load project permissions. Please refresh the page and try again.": "Failed to load project permissions. Please refresh the page and try again.", "Select namespace": "Select namespace", "No namespace found for \"{{filter}}\"": "No namespace found for \"{{filter}}\"", "Duplicate": "Duplicate", @@ -187,14 +185,15 @@ "Delete": "Delete", "Must be 75 or fewer characters long": "Must be 75 or fewer characters long", "Dashboard name '{{dashboardName}}' already exists in '{{projectName}}' project!": "Dashboard name '{{dashboardName}}' already exists in '{{projectName}}' project!", - "Checking permissions...": "Checking permissions...", "Create": "Create", "Dashboard actions": "Dashboard actions", "Import": "Import", - "To create dashboards, contact your cluster administrator for permission.": "To create dashboards, contact your cluster administrator for permission.", "Create Dashboard": "Create Dashboard", "my-new-dashboard": "my-new-dashboard", "Select project": "Select project", + "You do not have permission to create dashboards in this project.": "You do not have permission to create dashboards in this project.", + "You do not have permission to edit dashboards in this project.": "You do not have permission to edit dashboards in this project.", + "You do not have permission to delete dashboards in this project.": "You do not have permission to delete dashboards in this project.", "View and manage dashboards.": "View and manage dashboards.", "Unable to detect dashboard format. Please provide a valid Perses or Grafana dashboard.": "Unable to detect dashboard format. Please provide a valid Perses or Grafana dashboard.", "Invalid {{format}}: {{error}}": "Invalid {{format}}: {{error}}", @@ -219,7 +218,7 @@ "Rename dashboard": "Rename dashboard", "Duplicate dashboard": "Duplicate dashboard", "Delete dashboard": "Delete dashboard", - "You don't have permissions for dashboard actions": "You don't have permissions for dashboard actions", + "Kebab toggle": "Kebab toggle", "Dashboard": "Dashboard", "Project": "Project", "Created on": "Created on", @@ -234,19 +233,12 @@ "The dashboard \"{{name}}\" was not found in project \"{{project}}\".": "The dashboard \"{{name}}\" was not found in project \"{{project}}\".", "Empty Dashboard": "Empty Dashboard", "To get started add something to your dashboard": "To get started add something to your dashboard", + "Loading...": "Loading...", "Edit": "Edit", - "You don't have permission to edit this dashboard": "You don't have permission to edit this dashboard", "No Dashboard Available in Selected Project": "No Dashboard Available in Selected Project", "To explore data, create a dashboard for this project": "To explore data, create a dashboard for this project", "No Perses Project Available": "No Perses Project Available", "To explore data, create a Perses Project": "To explore data, create a Perses Project", - "Project is required for fetching project dashboards": "Project is required for fetching project dashboards", - "No projects found": "No projects found", - "No results match the filter criteria.": "No results match the filter criteria.", - "Clear filters": "Clear filters", - "Select project...": "Select project...", - "Projects": "Projects", - "All Projects": "All Projects", "useToast must be used within ToastProvider": "useToast must be used within ToastProvider", "Refresh off": "Refresh off", "{{count}} second_one": "{{count}} second", diff --git a/web/src/components/dashboards/perses/dashboard-action-modals.tsx b/web/src/components/dashboards/perses/dashboard-action-modals.tsx index 37f463849..0c1d0773a 100644 --- a/web/src/components/dashboards/perses/dashboard-action-modals.tsx +++ b/web/src/components/dashboards/perses/dashboard-action-modals.tsx @@ -11,7 +11,6 @@ import { ModalFooter, ModalHeader, ModalVariant, - Spinner, Stack, StackItem, TextInput, @@ -30,7 +29,6 @@ import { } from './dashboard-action-validations'; import { useCreateDashboardMutation, - useCreateProjectMutation, useDeleteDashboardMutation, useUpdateDashboardMutation, } from './dashboard-api'; @@ -43,8 +41,13 @@ import { useNavigate } from 'react-router-dom-v5-compat'; import { getDashboardUrl, usePerspective } from '../../hooks/usePerspective'; import { useToast } from './ToastProvider'; import { generateMetadataName } from './dashboard-utils'; -import { useEditableProjects } from './hooks/useEditableProjects'; -import { usePerses } from './hooks/usePerses'; +import { + DashboardDeniedHelperText, + useDashboardProjects, + useProjectCreation, +} from './dashboard-dialog-helpers'; +import { useOcpProjects } from './hooks/useOcpProjects'; +import { usePersesDashboardAccess } from './hooks/usePersesDashboardAccess'; export const formGroupStyle = { fontWeight: t_global_font_weight_200.value, @@ -72,6 +75,12 @@ export const RenameActionModal = ({ dashboard, isOpen, onClose }: ActionModalPro }); const updateDashboardMutation = useUpdateDashboardMutation(); + const [canUpdate, updateChecking] = usePersesDashboardAccess( + 'update', + dashboard?.metadata?.project ?? null, + isOpen && !!dashboard?.metadata?.project, + ); + const updateDenied = !updateChecking && !canUpdate; if (!dashboard) { return null; @@ -153,6 +162,7 @@ export const RenameActionModal = ({ dashboard, isOpen, onClose }: ActionModalPro )} /> + @@ -443,7 +460,7 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal - )} + } ); }; @@ -454,6 +471,12 @@ export const DeleteActionModal = ({ dashboard, isOpen, onClose }: ActionModalPro const deleteDashboardMutation = useDeleteDashboardMutation(); const dashboardName = dashboard?.spec?.display?.name ?? t('this dashboard'); + const [canDelete, deleteChecking] = usePersesDashboardAccess( + 'delete', + dashboard?.metadata?.project ?? null, + isOpen && !!dashboard?.metadata?.project, + ); + const deleteDenied = !deleteChecking && !canDelete; const handleDeleteConfirm = async () => { if (!dashboard) return; @@ -493,12 +516,15 @@ export const DeleteActionModal = ({ dashboard, isOpen, onClose }: ActionModalPro {t('Are you sure you want to delete ')} {dashboardName} {t('? This action can not be undone.')} + ); - if (disabled && !loading) { + if (!updateLoading && !canUpdate) { return ( - + {button} ); @@ -100,34 +99,6 @@ export const EditButton = ({ onClick, activeProject }: EditButtonProps): ReactEl return button; }; -export const usePersesEditPermissions = (namespace: string | null = null) => { - const [canCreate, createLoading] = useAccessReview({ - group: 'perses.dev', - resource: 'persesdashboards', - verb: 'create', - namespace, - }); - - const [canUpdate, updateLoading] = useAccessReview({ - group: 'perses.dev', - resource: 'persesdashboards', - verb: 'update', - namespace, - }); - - const [canDelete, deleteLoading] = useAccessReview({ - group: 'perses.dev', - resource: 'persesdashboards', - verb: 'delete', - namespace, - }); - - const loading = createLoading || updateLoading || deleteLoading; - const canEdit = canUpdate && canCreate && canDelete; - - return { canEdit, loading }; -}; - export const OCPDashboardToolbar = (props: DashboardToolbarProps): ReactElement => { const { initialVariableIsSticky, diff --git a/web/src/components/dashboards/perses/hooks/useAccessReview.ts b/web/src/components/dashboards/perses/hooks/useAccessReview.ts new file mode 100644 index 000000000..e633b95dd --- /dev/null +++ b/web/src/components/dashboards/perses/hooks/useAccessReview.ts @@ -0,0 +1,24 @@ +import { AccessReviewResourceAttributes, checkAccess } from '@openshift-console/dynamic-plugin-sdk'; +import { useQuery } from '@tanstack/react-query'; + +export const useAccessReview = ( + resourceAttributes: AccessReviewResourceAttributes, + enabled = true, +): [boolean, boolean] => { + const { + group = '', + resource = '', + subresource = '', + verb = '', + name = '', + namespace = '', + } = resourceAttributes; + const { data, isError, isLoading } = useQuery({ + queryKey: ['access-review', group, resource, subresource, verb, name, namespace], + queryFn: () => checkAccess(resourceAttributes).then((result) => result.status.allowed), + enabled, + retry: false, + }); + + return [isError || data === true, enabled && isLoading]; +}; diff --git a/web/src/components/dashboards/perses/hooks/useActiveProject.tsx b/web/src/components/dashboards/perses/hooks/useActiveProject.tsx new file mode 100644 index 000000000..f3c87271e --- /dev/null +++ b/web/src/components/dashboards/perses/hooks/useActiveProject.tsx @@ -0,0 +1,50 @@ +import { + K8sResourceKind, + useActiveNamespace, + useK8sWatchResource, +} from '@openshift-console/dynamic-plugin-sdk'; +import { useEffect } from 'react'; +import { ProjectModel } from '../../../console/models'; +import { QueryParams } from '../../../query-params'; +import { StringParam, useQueryParam } from 'use-query-params'; +import { ALL_NAMESPACES_KEY } from '../../../utils'; + +export const useActiveProject = () => { + const [activeNamespace, setActiveNamespace] = useActiveNamespace(); + const [projectFromUrl, setProject] = useQueryParam(QueryParams.Project, StringParam); + const [namespaces, namespacesLoaded] = useK8sWatchResource({ + isList: true, + kind: ProjectModel.kind, + optional: true, + }); + + useEffect(() => { + if (!namespacesLoaded || projectFromUrl === activeNamespace) { + return; + } + + if (!projectFromUrl) { + setProject(activeNamespace); + return; + } + + if ( + namespaces.some((namespace) => namespace.metadata.name === projectFromUrl) || + projectFromUrl === ALL_NAMESPACES_KEY + ) { + setActiveNamespace(projectFromUrl); + } + }, [ + activeNamespace, + setActiveNamespace, + namespaces, + namespacesLoaded, + projectFromUrl, + setProject, + ]); + + return { + activeProject: projectFromUrl, + setActiveProject: setProject, + }; +}; diff --git a/web/src/components/dashboards/perses/hooks/useDashboardsData.ts b/web/src/components/dashboards/perses/hooks/useDashboardsData.ts index 2fa0c53b0..476086540 100644 --- a/web/src/components/dashboards/perses/hooks/useDashboardsData.ts +++ b/web/src/components/dashboards/perses/hooks/useDashboardsData.ts @@ -7,8 +7,10 @@ import { getAllQueryArguments } from '../../../console/utils/router'; import { useBoolean } from '../../../hooks/useBoolean'; import { getDashboardUrl, usePerspective } from '../../../hooks/usePerspective'; import { QueryParams } from '../../../query-params'; -import { useActiveProject } from '../project/useActiveProject'; +import { useActiveProject } from './useActiveProject'; +import { useOcpProjects } from './useOcpProjects'; import { usePerses } from './usePerses'; +import { ALL_NAMESPACES_KEY } from '../../../utils'; // This hook syncs with mutliple external API's, redux, and URL state. Its a lot, but needs to all // be in a single location @@ -16,15 +18,14 @@ export const useDashboardsData = () => { const navigate = useNavigate(); const { perspective } = usePerspective(); const { activeProject, setActiveProject } = useActiveProject(); + const { ocpProjectsLoaded } = useOcpProjects(); // track initial page load to prevent a full page loading state when swapping dashboards // or projects const [initialPageLoad, , , setInitialPageLoadFalse] = useBoolean(true); // Retrieve perses dashboard information - const { persesProjects, persesProjectsLoading, persesDashboards, persesDashboardsLoading } = - usePerses(); - const persesAvailable = !persesProjectsLoading && persesProjects; + const { persesProjectsLoading, persesDashboards, persesDashboardsLoading } = usePerses(); const [dashboardName] = useQueryParam(QueryParams.Dashboard, StringParam); // Determine when to stop having the full page loader be used @@ -32,12 +33,18 @@ export const useDashboardsData = () => { if (!initialPageLoad) { return false; } - if (!(persesProjectsLoading || persesDashboardsLoading)) { + if (ocpProjectsLoaded && !persesProjectsLoading && !persesDashboardsLoading) { setInitialPageLoadFalse(); return false; } return true; - }, [persesProjectsLoading, persesDashboardsLoading, initialPageLoad, setInitialPageLoadFalse]); + }, [ + ocpProjectsLoaded, + persesProjectsLoading, + persesDashboardsLoading, + initialPageLoad, + setInitialPageLoadFalse, + ]); const prevDashboardsRef = useRef([]); const prevMetadataRef = useRef([]); @@ -85,7 +92,7 @@ export const useDashboardsData = () => { // Retrieve dashboard metadata for the currently selected project const activeProjectDashboardsMetadata = useMemo(() => { - if (!activeProject) { + if (activeProject === ALL_NAMESPACES_KEY) { return combinedDashboardsMetadata; } return combinedDashboardsMetadata.filter((combinedDashboardMetadata) => { @@ -106,7 +113,8 @@ export const useDashboardsData = () => { const params = new URLSearchParams(queryArguments); const dashboard = combinedDashboardsMetadata.find((item) => item.name === newBoard); - const projectToUse = activeProject || dashboard?.project; + const projectToUse = + activeProject === ALL_NAMESPACES_KEY ? dashboard?.project : activeProject; if (projectToUse) { params.set(QueryParams.Project, projectToUse); @@ -130,7 +138,6 @@ export const useDashboardsData = () => { ); return { - persesAvailable, persesProjectsLoading, persesDashboards, dashboardName, diff --git a/web/src/components/dashboards/perses/hooks/useEditableProjects.ts b/web/src/components/dashboards/perses/hooks/useEditableProjects.ts deleted file mode 100644 index 9e9640980..000000000 --- a/web/src/components/dashboards/perses/hooks/useEditableProjects.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { useMemo } from 'react'; -import { useSelector } from 'react-redux'; -import { PersesUserPermissions, useFetchPersesPermissions } from '../perses-client'; -import { useOcpProjects } from './useOcpProjects'; -import { K8sResourceKind } from '@openshift-console/dynamic-plugin-sdk'; - -interface Projects { - editableProjects: string[] | undefined; - allProjects: string[] | undefined; -} - -const useUsername = (): string => { - const getUser = (state: any) => state.sdkCore?.user; - const user = useSelector(getUser); - return user?.metadata?.name || user?.username; -}; - -const combinePersesAndOcpProjects = ( - persesUserPermissions: PersesUserPermissions, - ocpProjects: K8sResourceKind[], -): string[] => { - const persesProjectNames = Object.keys(persesUserPermissions).filter((name) => name !== '*'); - const allAvailableProjects = new Set([...persesProjectNames]); - ocpProjects.forEach((project) => { - if (project.metadata?.name) { - allAvailableProjects.add(project.metadata.name); - } - }); - return Array.from(allAvailableProjects); -}; - -const getEditableProjects = ( - persesUserPermissions: PersesUserPermissions, - allAvailableProjects: string[], -): string[] => { - const editableProjectNames = new Set(); - Object.entries(persesUserPermissions).forEach(([projectName, permissions]) => { - const hasDashboardPermissions = permissions.some((permission) => { - const allActions = permission.actions.includes('*'); - const individualActions = - permission.actions.includes('create') && - permission.actions.includes('update') && - permission.actions.includes('delete'); - const hasPermission = - permission.scopes.includes('Dashboard') && (individualActions || allActions); - return hasPermission; - }); - - if (hasDashboardPermissions) { - if (projectName === '*') { - allAvailableProjects.forEach((p) => editableProjectNames.add(p)); - } else { - editableProjectNames.add(projectName); - } - } - }); - return Array.from(editableProjectNames); -}; - -export const useEditableProjects = () => { - const username = useUsername(); - const { ocpProjects } = useOcpProjects(); - - const { persesUserPermissions, persesPermissionsLoading, persesPermissionsError } = - useFetchPersesPermissions(username); - - const { editableProjects, allProjects }: Projects = useMemo(() => { - if (persesPermissionsLoading) { - return { - editableProjects: undefined, - allProjects: undefined, - }; - } - if (!persesUserPermissions) { - return { - editableProjects: undefined, - allProjects: undefined, - }; - } - if (persesPermissionsError) { - return { - editableProjects: undefined, - allProjects: undefined, - }; - } - - const allAvailableProjects = combinePersesAndOcpProjects(persesUserPermissions, ocpProjects); - const editableProjectNames = getEditableProjects(persesUserPermissions, allAvailableProjects); - - // Sort projects alphabetically - const sortedEditableProjects = editableProjectNames.sort((a, b) => a.localeCompare(b)); - const sortedProjects = allAvailableProjects.sort((a, b) => a.localeCompare(b)); - - return { - editableProjects: sortedEditableProjects, - allProjects: sortedProjects, - }; - }, [persesPermissionsLoading, persesUserPermissions, persesPermissionsError, ocpProjects]); - - const hasEditableProject = useMemo(() => { - return editableProjects ? editableProjects.length > 0 : false; - }, [editableProjects]); - - return { - editableProjects, - allProjects, - hasEditableProject, - permissionsLoading: persesPermissionsLoading, - permissionsError: persesPermissionsError, - }; -}; diff --git a/web/src/components/dashboards/perses/hooks/usePerses.ts b/web/src/components/dashboards/perses/hooks/usePerses.ts index 10e6cacf3..df9b144bc 100644 --- a/web/src/components/dashboards/perses/hooks/usePerses.ts +++ b/web/src/components/dashboards/perses/hooks/usePerses.ts @@ -1,15 +1,9 @@ -import { - fetchPersesProjects, - fetchPersesDashboardsMetadata, - fetchPersesDashboardsByProject, -} from '../perses-client'; +import { fetchPersesProjects, fetchPersesDashboardsMetadata } from '../perses-client'; import { useQuery } from '@tanstack/react-query'; import { NumberParam, useQueryParam } from 'use-query-params'; import { QueryParams } from '../../../query-params'; -import { useTranslation } from 'react-i18next'; -export const usePerses = (project?: string | number) => { - const { t } = useTranslation(process.env.I18N_NAMESPACE); +export const usePerses = () => { const [refreshInterval] = useQueryParam(QueryParams.RefreshInterval, NumberParam); const { @@ -30,39 +24,17 @@ export const usePerses = (project?: string | number) => { } = useQuery({ queryKey: ['dashboards'], queryFn: fetchPersesDashboardsMetadata, - enabled: !project, // Only fetch all dashboards when no specific project is requested - refetchInterval: refreshInterval, - }); - - const { - isLoading: persesProjectDashboardsLoading, - error: persesProjectDashboardsError, - data: persesProjectDashboards, - } = useQuery({ - queryKey: ['dashboards', 'project', project], - queryFn: () => { - if (project === undefined || project === null) { - throw new Error(t('Project is required for fetching project dashboards')); - } - return fetchPersesDashboardsByProject(String(project)); - }, - enabled: !!project, + enabled: true, refetchInterval: refreshInterval, }); return { - // All Dashboards - fallback to project dashboards when all dashboards query is disabled - persesDashboards: persesDashboards ?? persesProjectDashboards ?? [], - persesDashboardsError: persesDashboardsError ?? persesProjectDashboardsError, - persesDashboardsLoading: - persesDashboardsLoading || (!!project && persesProjectDashboardsLoading), + persesDashboards: persesDashboards ?? [], + persesDashboardsError, + persesDashboardsLoading, // All Projects persesProjectsLoading, persesProjects: persesProjects ?? [], persesProjectsError, - // Dashboards of a given project - persesProjectDashboards: persesProjectDashboards ?? [], - persesProjectDashboardsError, - persesProjectDashboardsLoading, }; }; diff --git a/web/src/components/dashboards/perses/hooks/usePersesDashboardAccess.ts b/web/src/components/dashboards/perses/hooks/usePersesDashboardAccess.ts new file mode 100644 index 000000000..f8aa83752 --- /dev/null +++ b/web/src/components/dashboards/perses/hooks/usePersesDashboardAccess.ts @@ -0,0 +1,18 @@ +import { AccessReviewResourceAttributes } from '@openshift-console/dynamic-plugin-sdk'; +import { useAccessReview } from './useAccessReview'; + +export type DashboardVerb = 'create' | 'update' | 'delete'; + +export const usePersesDashboardAccess = ( + verb: DashboardVerb, + namespace: string | null = null, + enabled = true, +): [boolean, boolean] => { + const resourceAttributes: AccessReviewResourceAttributes = { + group: 'perses.dev', + resource: 'persesdashboards', + verb, + namespace, + }; + return useAccessReview(resourceAttributes, enabled && !!namespace); +}; diff --git a/web/src/components/dashboards/perses/perses-client.ts b/web/src/components/dashboards/perses/perses-client.ts index 9720ab763..1428a5dc9 100644 --- a/web/src/components/dashboards/perses/perses-client.ts +++ b/web/src/components/dashboards/perses/perses-client.ts @@ -1,8 +1,8 @@ +import { consoleFetchJSON } from '@openshift-console/dynamic-plugin-sdk'; +import { DashboardResource, ProjectResource } from '@perses-dev/core'; import { useQuery } from '@tanstack/react-query'; -import { DashboardResource, ProjectResource, fetchJson } from '@perses-dev/core'; import { NumberParam, useQueryParam } from 'use-query-params'; import { QueryParams } from '../../query-params'; -import { getCSRFToken } from '@openshift-console/dynamic-plugin-sdk/lib/utils/fetch/console-fetch-utils'; export const PERSES_PROXY_BASE_PATH = '/api/proxy/plugin/monitoring-console-plugin/perses'; @@ -10,48 +10,16 @@ export const fetchPersesDashboardsMetadata = (): Promise => const listDashboardsMetadata = '/api/v1/dashboards'; const persesURL = `${PERSES_PROXY_BASE_PATH}${listDashboardsMetadata}`; - return ocpPersesFetchJson(persesURL); -}; - -export const fetchPersesDashboardsByProject = (project: string): Promise => { - const dashboardsEndpoint = `${PERSES_PROXY_BASE_PATH}/api/v1/dashboards`; - const persesURL = `${dashboardsEndpoint}?project=${encodeURIComponent(project)}`; - - return ocpPersesFetchJson(persesURL); + return consoleFetchJSON(persesURL); }; export const fetchPersesProjects = (): Promise => { const listProjectURL = '/api/v1/projects'; const persesURL = `${PERSES_PROXY_BASE_PATH}${listProjectURL}`; - return ocpPersesFetchJson(persesURL); -}; - -export interface PersesPermission { - scopes: string[]; - actions: string[]; -} - -export type PersesUserPermissions = { - [projectName: string]: PersesPermission[]; + return consoleFetchJSON(persesURL); }; -export const fetchPersesUserPermissions = (username: string): Promise => { - const userPermissionsURL = `/api/v1/users/${encodeURIComponent(username)}/permissions`; - const persesURL = `${PERSES_PROXY_BASE_PATH}${userPermissionsURL}`; - - return ocpPersesFetchJson(persesURL); -}; - -export async function ocpPersesFetchJson(url: string): Promise { - // Use perses fetch as base fetch call as it handles refresh tokens - return fetchJson(url, { - headers: { - 'X-CSRFToken': getCSRFToken(), - }, - }); -} - export const fetchPersesDashboard = async ( project: string, dashboardName: string, @@ -59,7 +27,7 @@ export const fetchPersesDashboard = async ( const getDashboardURL = `/api/v1/projects/${project}/dashboards/${dashboardName}`; const persesURL = `${PERSES_PROXY_BASE_PATH}${getDashboardURL}`; - return await ocpPersesFetchJson(persesURL); + return await consoleFetchJSON(persesURL); }; export const useFetchPersesDashboard = (project: string, dashboardName: string) => { @@ -82,28 +50,3 @@ export const useFetchPersesDashboard = (project: string, dashboardName: string) persesDashboardLoading, }; }; - -export const useFetchPersesPermissions = (username: string) => { - const { - isLoading: persesPermissionsLoading, - error: persesPermissionsError, - data: persesUserPermissions, - } = useQuery({ - queryKey: ['perses-user-permissions', username], - queryFn: () => fetchPersesUserPermissions(username), - enabled: !!username, - staleTime: 5 * 60 * 1000, // Cache for 5 minutes - refetchOnWindowFocus: true, - retry: 2, - onError: (error) => { - // eslint-disable-next-line no-console - console.warn('Failed to fetch Perses user permissions:', error); - }, - }); - - return { - persesUserPermissions, - persesPermissionsError, - persesPermissionsLoading, - }; -}; diff --git a/web/src/components/dashboards/perses/perses/datasource-client.ts b/web/src/components/dashboards/perses/perses/datasource-client.ts index 690ff61da..d79c9290a 100644 --- a/web/src/components/dashboards/perses/perses/datasource-client.ts +++ b/web/src/components/dashboards/perses/perses/datasource-client.ts @@ -11,9 +11,9 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { consoleFetchJSON } from '@openshift-console/dynamic-plugin-sdk'; import { DatasourceResource } from '@perses-dev/core'; import buildURL from './url-builder'; -import { ocpPersesFetchJson } from '../perses-client'; export const resource = 'datasources'; @@ -46,5 +46,5 @@ export function fetchDatasourceList( project: project, queryParams: buildDatasourceQueryParameters(kind, defaultDatasource, name), }); - return ocpPersesFetchJson(url); + return consoleFetchJSON(url) as Promise; } diff --git a/web/src/components/dashboards/perses/perses/global-datasource-client.ts b/web/src/components/dashboards/perses/perses/global-datasource-client.ts index b4f95d428..924d3f865 100644 --- a/web/src/components/dashboards/perses/perses/global-datasource-client.ts +++ b/web/src/components/dashboards/perses/perses/global-datasource-client.ts @@ -11,10 +11,10 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { consoleFetchJSON } from '@openshift-console/dynamic-plugin-sdk'; import { GlobalDatasourceResource } from '@perses-dev/core'; import buildURL from './url-builder'; import { buildDatasourceQueryParameters } from './datasource-client'; -import { ocpPersesFetchJson } from '../perses-client'; const globalDatasourceResource = 'globaldatasources'; @@ -27,5 +27,5 @@ export function fetchGlobalDatasourceList( resource: globalDatasourceResource, queryParams: buildDatasourceQueryParameters(kind, defaultDatasource, name), }); - return ocpPersesFetchJson(url); + return consoleFetchJSON(url) as Promise; } diff --git a/web/src/components/dashboards/perses/project/ProjectBar.tsx b/web/src/components/dashboards/perses/project/ProjectBar.tsx deleted file mode 100644 index 1d785a7db..000000000 --- a/web/src/components/dashboards/perses/project/ProjectBar.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type { SetStateAction, Dispatch, FC } from 'react'; -import { useNavigate } from 'react-router-dom-v5-compat'; -import { KEYBOARD_SHORTCUTS } from './utils'; -import { getDashboardsListUrl, usePerspective } from '../../../hooks/usePerspective'; -import ProjectDropdown from './ProjectDropdown'; - -export type ProjectBarProps = { - setActiveProject: Dispatch>; - activeProject: string | null; -}; - -export const ProjectBar: FC = ({ setActiveProject, activeProject }) => { - const navigate = useNavigate(); - const { perspective } = usePerspective(); - - return ( -
-
- { - const params = new URLSearchParams(); - if (newProject === '') { - setActiveProject(null); - } else { - params.set('project', newProject); - setActiveProject(newProject); - } - const url = `${getDashboardsListUrl(perspective)}?${params.toString()}`; - navigate(url); - }} - selected={activeProject || ''} - shortCut={KEYBOARD_SHORTCUTS.focusNamespaceDropdown} - /> -
-
- ); -}; diff --git a/web/src/components/dashboards/perses/project/ProjectDropdown.tsx b/web/src/components/dashboards/perses/project/ProjectDropdown.tsx deleted file mode 100644 index 006592414..000000000 --- a/web/src/components/dashboards/perses/project/ProjectDropdown.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import { - Button, - Divider, - EmptyState, - EmptyStateBody, - Menu, - MenuContent, - MenuGroup, - MenuSearch, - MenuSearchInput, - MenuItem, - MenuList, - TextInput, - EmptyStateActions, - EmptyStateFooter, - Tooltip, - TooltipPosition, -} from '@patternfly/react-core'; -import fuzzysearch from 'fuzzysearch'; -import { useTranslation } from 'react-i18next'; -import ProjectMenuToggle from './ProjectMenuToggle'; -import { alphanumericCompare } from './utils'; -import { useEditableProjects } from '../hooks/useEditableProjects'; -import { useCallback, useMemo, useRef, useState } from 'react'; -import { ALL_NAMESPACES_KEY } from '../../../utils'; - -export const NoResults: React.FC<{ - onClear: (event: React.MouseEvent) => void; -}> = ({ onClear }) => { - const { t } = useTranslation(process.env.I18N_NAMESPACE); - return ( - <> - - {t('No projects found')}}> - {t('No results match the filter criteria.')} - - - - - - - - ); -}; - -/* ****************************************** */ - -export const Filter: React.FC<{ - filterRef: React.Ref; - onFilterChange: (filterText: string) => void; - filterText: string; -}> = ({ filterText, filterRef, onFilterChange }) => { - const { t } = useTranslation(process.env.I18N_NAMESPACE); - return ( - - - onFilterChange(value)} - ref={filterRef} - /> - - - ); -}; - -/* ****************************************** */ - -export const ProjectGroup: React.FC<{ - options: { key: string; title: string }[]; - selectedKey: string; -}> = ({ options, selectedKey }) => { - const { t } = useTranslation(process.env.I18N_NAMESPACE); - - return options.length === 0 ? null : ( - <> - - - - {options.map((option) => { - return ( - - {option.title} - - ); - })} - - - - ); -}; - -/* ****************************************** */ - -const ProjectMenu: React.FC<{ - setOpen: (isOpen: boolean) => void; - onSelect: (event: React.MouseEvent, itemId: string) => void; - selected?: string; - menuRef: React.MutableRefObject; -}> = ({ setOpen, onSelect, selected, menuRef }) => { - const filterRef = useRef(null); - const { t } = useTranslation(process.env.I18N_NAMESPACE); - - const [filterText, setFilterText] = useState(''); - - const { allProjects } = useEditableProjects(); - - const optionItems = useMemo(() => { - const items = - allProjects?.map((projectName) => { - return { title: projectName, key: projectName }; - }) || []; - - if (selected && !items.some((option) => option.key === selected)) { - items.push({ title: selected, key: selected }); // Add current project if it isn't included - } - items.sort((a, b) => alphanumericCompare(a.title, b.title)); - items.unshift({ title: t('All Projects'), key: '' }); - - return items; - }, [allProjects, selected, t]); - - const isOptionShown = useCallback( - (option) => { - return fuzzysearch(filterText.toLowerCase(), option.title.toLowerCase()); - }, - [filterText], - ); - - const { filteredOptions } = useMemo( - () => - optionItems.reduce( - (filtered, option) => { - if (isOptionShown(option)) { - filtered.filteredOptions.push(option); - } - return filtered; - }, - { filteredOptions: [] }, - ), - [isOptionShown, optionItems], - ); - - return ( - { - setOpen(false); - onSelect(event, itemId); - }} - activeItemId={selected} - data-test="project-dropdown-menu" - isScrollable - > - - - {filteredOptions.length === 0 ? ( - { - event.preventDefault(); - event.stopPropagation(); - setFilterText(''); - filterRef.current?.focus(); - }} - /> - ) : null} - - - - ); -}; - -/* ****************************************** */ - -const ProjectDropdown: React.FC = ({ - disabled, - onSelect, - selected, - shortCut, -}) => { - const { t } = useTranslation(process.env.I18N_NAMESPACE); - const menuRef = useRef(null); - const [isOpen, setOpen] = useState(false); - const { allProjects, permissionsLoading, permissionsError } = useEditableProjects(); - - // const title = selected === LEGACY_DASHBOARDS_KEY ? legacyDashboardsTitle : selected; - - const menuProps = { - setOpen, - onSelect, - selected, - menuRef, - }; - - let title = t('All Projects'); - // While loading permissions, or if there is a permission error fallback to the "selected" value - // 'All Projects' is the user friendly ALL_NAMESPACES_KEY - if ( - selected && - (allProjects?.includes(selected) || permissionsLoading || !!permissionsError) && - selected !== ALL_NAMESPACES_KEY - ) { - title = selected; - } - - const toggle = ( - } - menuRef={menuRef} - isOpen={isOpen} - title={`${t('Project')}: ${title}`} - onToggle={(menuState) => { - setOpen(menuState); - }} - shortCut={shortCut} - /> - ); - - return ( -
- {permissionsLoading ? ( - - {toggle} - - ) : permissionsError ? ( - - {toggle} - - ) : ( - toggle - )} -
- ); -}; - -type ProjectDropdownProps = { - disabled?: boolean; - onSelect?: (event: React.MouseEvent | React.ChangeEvent, value: string) => void; - shortCut?: string; - selected?: string; -}; - -export default ProjectDropdown; diff --git a/web/src/components/dashboards/perses/project/ProjectMenuToggle.tsx b/web/src/components/dashboards/perses/project/ProjectMenuToggle.tsx deleted file mode 100644 index 99594d4d7..000000000 --- a/web/src/components/dashboards/perses/project/ProjectMenuToggle.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import type { ReactElement, RefObject } from 'react'; -import { useRef, useEffect } from 'react'; -import { MenuToggle, Popper } from '@patternfly/react-core'; -import classNames from 'classnames'; - -const ProjectMenuToggle = (props: { - disabled: boolean; - menu: ReactElement; - menuRef: RefObject; - isOpen: boolean; - shortCut?: string; - title: string; - onToggle: (state: boolean) => void; - className?: string; -}) => { - const { menu, isOpen, shortCut, title, onToggle, disabled, menuRef, className } = props; - - const toggleRef = useRef(null); - const containerRef = useRef(null); - - const handleMenuKeys = (event) => { - if ( - shortCut && - event.key === shortCut && - event.target.nodeName !== 'INPUT' && - event.target.nodeName !== 'TEXTAREA' && - event.target.role !== 'textbox' && - event.target.role !== 'code' - ) { - onToggle(true); - event.stopPropagation(); - event.preventDefault(); - } - - if (menuRef.current) { - if (event.key === 'Escape') { - onToggle(false); - toggleRef.current.focus(); - } - if (!menuRef.current?.contains(event.target) && event.key === 'Tab') { - onToggle(false); - } - } - }; - - const handleMenuClick = (event) => { - if ( - menuRef.current && - !menuRef.current?.contains(event.target) && - // Checking to see if user clicked on a favorite icon. This is needed because - // if unfavoriting a item, PF removes the item from the DOM before - // the click event is registered - !event.target.closest?.('.pf-m-favorite') && - !toggleRef.current.contains(event.target) - ) { - onToggle(false); - } - }; - - useEffect(() => { - window.addEventListener('keyup', handleMenuKeys); - window.addEventListener('click', handleMenuClick); - return () => { - window.removeEventListener('keyup', handleMenuKeys); - window.removeEventListener('click', handleMenuClick); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // This needs to be run only on component mount/unmount - - const toggle = ( - onToggle(!isOpen)} - isExpanded={isOpen} - disabled={disabled} - className={classNames('co-namespace-dropdown__menu-toggle', className)} - > - {title} - - ); - - return ( -
- -
- ); -}; - -export default ProjectMenuToggle; diff --git a/web/src/components/dashboards/perses/project/useActiveProject.tsx b/web/src/components/dashboards/perses/project/useActiveProject.tsx deleted file mode 100644 index 5643ada83..000000000 --- a/web/src/components/dashboards/perses/project/useActiveProject.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { - K8sResourceKind, - useActiveNamespace, - useK8sWatchResource, -} from '@openshift-console/dynamic-plugin-sdk'; -import { useState, useEffect } from 'react'; -import { ProjectModel } from '../../../console/models'; -import { usePerspective } from '../../../hooks/usePerspective'; -import { usePerses } from '../hooks/usePerses'; -import { QueryParams } from '../../../query-params'; -import { StringParam, useQueryParam } from 'use-query-params'; - -export const useActiveProject = () => { - const [activeProject, setActiveProject] = useState(null); - const [activeNamespace, setActiveNamespace] = useActiveNamespace(); - const { perspective } = usePerspective(); - const { persesProjects, persesProjectsLoading } = usePerses(); - const [projectFromUrl, setProject] = useQueryParam(QueryParams.Project, StringParam); - const [namespaces, namespacesLoaded] = useK8sWatchResource({ - isList: true, - kind: ProjectModel.kind, - optional: true, - }); - - // Sync the state and the URL param - useEffect(() => { - if (!activeProject && projectFromUrl) { - setActiveProject(projectFromUrl); - return; - } - if (persesProjectsLoading) { - return; - } - // If the url and the data is out of sync, follow the data - if (activeProject) { - setProject(activeProject); - } - }, [ - projectFromUrl, - activeProject, - perspective, - persesProjects, - persesProjectsLoading, - setProject, - ]); - - // Sync the activeProject and activeNamespace changes - useEffect(() => { - if (!activeProject || !namespacesLoaded || activeProject === activeNamespace) { - return; - } - - // If the project name exists as a namespace, set the active namespace - if (namespaces.some((namespace) => namespace.metadata.name === activeProject)) { - setActiveNamespace(activeProject); - } - }, [activeNamespace, setActiveNamespace, activeProject, namespaces, namespacesLoaded]); - - return { - activeProject, - setActiveProject, - }; -}; diff --git a/web/src/components/dashboards/perses/project/utils.ts b/web/src/components/dashboards/perses/project/utils.ts deleted file mode 100644 index 4a0352ea7..000000000 --- a/web/src/components/dashboards/perses/project/utils.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const alphanumericCompare = (a: string, b: string): number => { - const safeA = a || ''; - const safeB = b || ''; - - return safeA.localeCompare(safeB, undefined, { - numeric: true, - sensitivity: 'base', - }); -}; - -// Common shortcuts than span pages. -export const KEYBOARD_SHORTCUTS = Object.freeze({ - focusNamespaceDropdown: 'n', -}); diff --git a/web/src/components/data-test.ts b/web/src/components/data-test.ts index 87b377be6..55bdf2e46 100644 --- a/web/src/components/data-test.ts +++ b/web/src/components/data-test.ts @@ -292,6 +292,9 @@ export const persesDashboardDataTestIDs = { importDashboardButtonToolbar: 'import-dashboard-button-list-page', editDashboardButtonToolbar: 'edit-dashboard-button-toolbar', cancelButtonToolbar: 'cancel-button-toolbar', + createAccessDeniedHelperText: 'perses-create-access-denied-helper-text', + updateAccessDeniedHelperText: 'perses-update-access-denied-helper-text', + deleteAccessDeniedHelperText: 'perses-delete-access-denied-helper-text', }; export const listPersesDashboardsDataTestIDs = {