diff --git a/web/package-lock.json b/web/package-lock.json index fb1a8839..88c266f2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -53,6 +53,7 @@ "murmurhash-js": "1.0.x", "react": "^17.0.1", "react-dom": "^17.0.1", + "react-hook-form": "^7.66.0", "react-i18next": "^11.8.11", "react-linkify": "^0.2.2", "react-modal": "^3.12.1", diff --git a/web/package.json b/web/package.json index c1e65cc0..a6a6a736 100644 --- a/web/package.json +++ b/web/package.json @@ -89,6 +89,7 @@ "murmurhash-js": "1.0.x", "react": "^17.0.1", "react-dom": "^17.0.1", + "react-hook-form": "^7.66.0", "react-i18next": "^11.8.11", "react-linkify": "^0.2.2", "react-modal": "^3.12.1", diff --git a/web/src/components/dashboards/perses/dashboard-action-modals.tsx b/web/src/components/dashboards/perses/dashboard-action-modals.tsx new file mode 100644 index 00000000..8d7c5405 --- /dev/null +++ b/web/src/components/dashboards/perses/dashboard-action-modals.tsx @@ -0,0 +1,511 @@ +import { + Button, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + FormGroup, + TextInput, + FormHelperText, + HelperText, + HelperTextItem, + ValidatedOptions, + HelperTextItemVariant, + ModalVariant, + AlertVariant, + Select, + SelectOption, + SelectList, + MenuToggle, + MenuToggleElement, + Stack, + StackItem, + Spinner, +} from '@patternfly/react-core'; +import { ExclamationCircleIcon } from '@patternfly/react-icons'; +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + useUpdateDashboardMutation, + useCreateDashboardMutation, + useDeleteDashboardMutation, +} from './dashboard-api'; +import { + renameDashboardDialogValidationSchema, + RenameDashboardValidationType, + createDashboardDialogValidationSchema, + CreateDashboardValidationType, + useDashboardValidationSchema, +} from './dashboard-action-validations'; + +import { Controller, FormProvider, SubmitHandler, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { + DashboardResource, + getResourceDisplayName, + getResourceExtendedDisplayName, +} from '@perses-dev/core'; +import { useToast } from './ToastProvider'; +import { usePerses } from './hooks/usePerses'; +import { generateMetadataName } from './dashboard-utils'; +import { useProjectPermissions } from './dashboard-permissions'; +import { t_global_spacer_200, t_global_font_weight_200 } from '@patternfly/react-tokens'; +import { useNavigate } from 'react-router-dom-v5-compat'; +import { usePerspective, getDashboardUrl } from '../../hooks/usePerspective'; + +const formGroupStyle = { + fontWeight: t_global_font_weight_200.value, +} as React.CSSProperties; + +const LabelSpacer = () => { + return
; +}; + +interface ActionModalProps { + dashboard: DashboardResource; + isOpen: boolean; + onClose: () => void; + handleModalClose: () => void; +} + +export const RenameActionModal = ({ dashboard, isOpen, onClose }: ActionModalProps) => { + const { t } = useTranslation(process.env.I18N_NAMESPACE); + const { addAlert } = useToast(); + + const form = useForm({ + resolver: zodResolver(renameDashboardDialogValidationSchema(t)), + mode: 'onBlur', + defaultValues: { dashboardName: dashboard ? getResourceDisplayName(dashboard) : '' }, + }); + + const updateDashboardMutation = useUpdateDashboardMutation(); + + if (!dashboard) { + return null; + } + + const processForm: SubmitHandler = (data) => { + if (dashboard.spec?.display) { + dashboard.spec.display.name = data.dashboardName; + } else { + dashboard.spec.display = { name: data.dashboardName }; + } + + updateDashboardMutation.mutate(dashboard, { + onSuccess: (updatedDashboard: DashboardResource) => { + const msg = t( + `Dashboard ${getResourceExtendedDisplayName( + updatedDashboard, + )} has been successfully updated`, + ); + addAlert(msg, AlertVariant.success); + handleClose(); + }, + onError: (err) => { + const msg = t(`Could not rename dashboard. ${err}`); + addAlert(msg, AlertVariant.danger); + throw err; + }, + }); + }; + + const handleClose = () => { + onClose(); + form.reset(); + }; + + return ( + + + +
+ + ( + + + + {fieldState.error && ( + + + } + variant={HelperTextItemVariant.error} + > + {fieldState.error.message} + + + + )} + + )} + /> + + + + + +
+
+
+ ); +}; + +export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModalProps) => { + const { t } = useTranslation(process.env.I18N_NAMESPACE); + const { addAlert } = useToast(); + + const navigate = useNavigate(); + const { perspective } = usePerspective(); + const [isProjectSelectOpen, setIsProjectSelectOpen] = useState(false); + + const { persesProjects, persesProjectsLoading } = usePerses(); + + const hookInput = useMemo(() => { + return persesProjects || []; + }, [persesProjects]); + + const { editableProjects } = useProjectPermissions(hookInput); + + const filteredProjects = useMemo(() => { + return persesProjects.filter((project) => editableProjects.includes(project.metadata.name)); + }, [persesProjects, editableProjects]); + + const defaultProject = useMemo(() => { + if (!dashboard) return ''; + + if (dashboard.metadata.project && editableProjects.includes(dashboard.metadata.project)) { + return dashboard.metadata.project; + } + + return filteredProjects[0]?.metadata.name || ''; + }, [dashboard, editableProjects, filteredProjects]); + + const { schema: validationSchema } = useDashboardValidationSchema(defaultProject, t); + + const form = useForm({ + resolver: validationSchema + ? zodResolver(validationSchema) + : zodResolver(createDashboardDialogValidationSchema(t)), + mode: 'onBlur', + defaultValues: { + projectName: defaultProject, + dashboardName: '', + }, + }); + + const createDashboardMutation = useCreateDashboardMutation(); + + React.useEffect(() => { + if (isOpen && dashboard && filteredProjects.length > 0 && defaultProject) { + form.reset({ + projectName: defaultProject, + dashboardName: '', + }); + } + }, [isOpen, dashboard, defaultProject, filteredProjects.length, form]); + + const selectedProjectName = form.watch('projectName'); + const selectedProjectDisplay = useMemo(() => { + const selectedProject = filteredProjects.find((p) => p.metadata.name === selectedProjectName); + return selectedProject + ? getResourceDisplayName(selectedProject) + : selectedProjectName || t('Select project'); + }, [filteredProjects, selectedProjectName, t]); + + if (!dashboard) { + return null; + } + + const processForm: SubmitHandler = (data) => { + const newDashboard: DashboardResource = { + ...dashboard, + metadata: { + ...dashboard.metadata, + name: generateMetadataName(data.dashboardName), + project: data.projectName, + }, + spec: { + ...dashboard.spec, + display: { + ...dashboard.spec.display, + name: data.dashboardName, + }, + }, + }; + + createDashboardMutation.mutate(newDashboard, { + onSuccess: (createdDashboard: DashboardResource) => { + const msg = t( + `Dashboard ${getResourceExtendedDisplayName( + createdDashboard, + )} has been successfully created`, + ); + addAlert(msg, AlertVariant.success); + + handleClose(); + + const dashboardUrl = getDashboardUrl(perspective); + const dashboardParam = `dashboard=${createdDashboard.metadata.name}`; + const projectParam = `project=${createdDashboard.metadata.project}`; + const editModeParam = `edit=true`; + navigate(`${dashboardUrl}?${dashboardParam}&${projectParam}&${editModeParam}`); + }, + onError: (err) => { + const msg = t(`Could not duplicate dashboard. ${err}`); + addAlert(msg, AlertVariant.danger); + }, + }); + }; + + const handleClose = () => { + onClose(); + form.reset(); + }; + + const onProjectToggle = () => { + setIsProjectSelectOpen(!isProjectSelectOpen); + }; + + const onProjectSelect = ( + _event: React.MouseEvent | undefined, + value: string | number | undefined, + ) => { + if (typeof value === 'string') { + form.setValue('projectName', value); + setIsProjectSelectOpen(false); + } + }; + + return ( + + + {persesProjectsLoading ? ( + + {t('Loading...')} + + ) : ( + +
+ + + + ( + + + + {fieldState.error && ( + + + } + variant={HelperTextItemVariant.error} + > + {fieldState.error.message} + + + + )} + + )} + /> + + + ( + + + + {fieldState.error && ( + + + } + variant={HelperTextItemVariant.error} + > + {fieldState.error.message} + + + + )} + + )} + /> + + + + + + + +
+
+ )} +
+ ); +}; + +export const DeleteActionModal = ({ dashboard, isOpen, onClose }: ActionModalProps) => { + const { t } = useTranslation(process.env.I18N_NAMESPACE); + const { addAlert } = useToast(); + + const deleteDashboardMutation = useDeleteDashboardMutation(); + const dashboardName = dashboard?.spec?.display?.name ?? t('this dashboard'); + + const handleDeleteConfirm = async () => { + if (!dashboard) return; + + deleteDashboardMutation.mutate(dashboard, { + onSuccess: (deletedDashboard: DashboardResource) => { + const msg = t( + `Dashboard ${getResourceExtendedDisplayName( + deletedDashboard, + )} has been successfully deleted`, + ); + addAlert(msg, AlertVariant.success); + onClose(); + }, + onError: (err) => { + const msg = t(`Could not delete dashboard. ${err}`); + addAlert(msg, AlertVariant.danger); + throw err; + }, + }); + }; + + return ( + + + + {t('Are you sure you want to delete ')} + {dashboardName} + {t('? This action can not be undone.')} + + + + + + + ); +}; diff --git a/web/src/components/dashboards/perses/dashboard-action-validations.ts b/web/src/components/dashboards/perses/dashboard-action-validations.ts new file mode 100644 index 00000000..907362c6 --- /dev/null +++ b/web/src/components/dashboards/perses/dashboard-action-validations.ts @@ -0,0 +1,92 @@ +import { z } from 'zod'; +import { useMemo } from 'react'; +import { nameSchema } from '@perses-dev/core'; +import { useDashboardList } from './dashboard-api'; +import { generateMetadataName } from './dashboard-utils'; + +export const createDashboardDisplayNameValidationSchema = (t: (key: string) => string) => + z.string().min(1, t('Required')).max(75, t('Must be 75 or fewer characters long')); + +export const createDashboardDialogValidationSchema = (t?: (key: string) => string) => + z.object({ + projectName: nameSchema, + dashboardName: createDashboardDisplayNameValidationSchema(t), + }); + +export const renameDashboardDialogValidationSchema = (t?: (key: string) => string) => + z.object({ + dashboardName: createDashboardDisplayNameValidationSchema(t), + }); + +export type CreateDashboardValidationType = z.infer< + ReturnType +>; +export type RenameDashboardValidationType = z.infer< + ReturnType +>; + +export interface DashboardValidationSchema { + schema?: z.ZodSchema; + isSchemaLoading: boolean; + hasSchemaError: boolean; +} + +// Validate dashboard name and check if it doesn't already exist +export function useDashboardValidationSchema( + projectName?: string, + t?: (key: string, options?: any) => string, +): DashboardValidationSchema { + const { + data: dashboards, + isLoading: isDashboardsLoading, + isError, + } = useDashboardList({ project: projectName }); + return useMemo((): DashboardValidationSchema => { + if (isDashboardsLoading) + return { + schema: undefined, + isSchemaLoading: true, + hasSchemaError: false, + }; + + if (isError) { + return { + hasSchemaError: true, + isSchemaLoading: false, + schema: undefined, + }; + } + + if (!dashboards?.length) + return { + schema: createDashboardDialogValidationSchema(t), + isSchemaLoading: true, + hasSchemaError: false, + }; + + const refinedSchema = createDashboardDialogValidationSchema(t).refine( + (schema) => { + return !(dashboards ?? []).some((dashboard) => { + return ( + dashboard.metadata.project.toLowerCase() === schema.projectName.toLowerCase() && + dashboard.metadata.name.toLowerCase() === + generateMetadataName(schema.dashboardName).toLowerCase() + ); + }); + }, + (schema) => ({ + // eslint-disable-next-line max-len + message: t + ? t(`Dashboard name '{{dashboardName}}' already exists in '{{projectName}}' project!`, { + dashboardName: schema.dashboardName, + projectName: schema.projectName, + }) + : // eslint-disable-next-line max-len + `Dashboard name '${schema.dashboardName}' already exists in '${schema.projectName}' project!`, + path: ['dashboardName'], + }), + ); + + return { schema: refinedSchema, isSchemaLoading: true, hasSchemaError: false }; + }, [dashboards, isDashboardsLoading, isError, t]); +} diff --git a/web/src/components/dashboards/perses/dashboard-api.ts b/web/src/components/dashboards/perses/dashboard-api.ts index bee69eaf..69a3b073 100644 --- a/web/src/components/dashboards/perses/dashboard-api.ts +++ b/web/src/components/dashboards/perses/dashboard-api.ts @@ -2,6 +2,8 @@ import { DashboardResource } from '@perses-dev/core'; import buildURL from './perses/url-builder'; import { useMutation, UseMutationResult, useQueryClient } from '@tanstack/react-query'; import { consoleFetchJSON } from '@openshift-console/dynamic-plugin-sdk'; +import { useQuery, UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import { StatusError } from '@perses-dev/core'; const resource = 'dashboards'; @@ -54,3 +56,68 @@ export const useCreateDashboardMutation = ( }, }); }; + +const deleteDashboard = async (entity: DashboardResource): Promise => { + const url = buildURL({ + resource: resource, + project: entity.metadata.project, + name: entity.metadata.name, + }); + + await consoleFetchJSON.delete(url); +}; + +export function useDeleteDashboardMutation(): UseMutationResult< + DashboardResource, + Error, + DashboardResource +> { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: [resource], + mutationFn: (entity: DashboardResource) => { + return deleteDashboard(entity).then(() => { + return entity; + }); + }, + onSuccess: (dashboard) => { + queryClient.removeQueries({ + queryKey: [resource, dashboard.metadata.project, dashboard.metadata.name], + }); + return queryClient.invalidateQueries({ queryKey: [resource] }); + }, + }); +} + +export const getDashboards = async ( + project?: string, + metadataOnly: boolean = false, +): Promise => { + const queryParams = new URLSearchParams(); + if (metadataOnly) { + queryParams.set('metadata_only', 'true'); + } + const url = buildURL({ resource: resource, project: project, queryParams: queryParams }); + + return consoleFetchJSON(url); +}; + +type DashboardListOptions = Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' +> & { + project?: string; + metadataOnly?: boolean; +}; + +export function useDashboardList( + options: DashboardListOptions, +): UseQueryResult { + return useQuery({ + queryKey: [resource, options.project, options.metadataOnly], + queryFn: () => { + return getDashboards(options.project, options.metadataOnly); + }, + ...options, + }); +} diff --git a/web/src/components/dashboards/perses/dashboard-create-dialog.tsx b/web/src/components/dashboards/perses/dashboard-create-dialog.tsx index a64a4a50..803dbec8 100644 --- a/web/src/components/dashboards/perses/dashboard-create-dialog.tsx +++ b/web/src/components/dashboards/perses/dashboard-create-dialog.tsx @@ -21,7 +21,6 @@ import { HelperTextItemVariant, ValidatedOptions, } from '@patternfly/react-core'; -import { useQuery } from '@tanstack/react-query'; import { ExclamationCircleIcon } from '@patternfly/react-icons'; import { usePerses } from './hooks/usePerses'; import { useTranslation } from 'react-i18next'; @@ -36,92 +35,7 @@ import { useToast } from './ToastProvider'; import { usePerspective, getDashboardUrl } from '../../hooks/usePerspective'; import { usePersesEditPermissions } from './dashboard-toolbar'; import { persesDashboardDataTestIDs } from '../../data-test'; -import { checkAccess } from '@openshift-console/dynamic-plugin-sdk'; - -const checkProjectPermissions = async (projects: any[]): Promise => { - if (!projects || projects.length === 0) { - return []; - } - - const editableProjectNames: string[] = []; - - for (const project of projects) { - const projectName = project?.metadata?.name; - if (!projectName) continue; - - try { - const [createResult, updateResult, deleteResult] = await Promise.all([ - checkAccess({ - group: 'perses.dev', - resource: 'persesdashboards', - verb: 'create', - namespace: projectName, - }), - checkAccess({ - group: 'perses.dev', - resource: 'persesdashboards', - verb: 'update', - namespace: projectName, - }), - checkAccess({ - group: 'perses.dev', - resource: 'persesdashboards', - verb: 'delete', - namespace: projectName, - }), - ]); - - const canEdit = - createResult.status.allowed && updateResult.status.allowed && deleteResult.status.allowed; - - if (canEdit) { - editableProjectNames.push(projectName); - } - } catch (error) { - // eslint-disable-next-line no-console - console.warn(`Failed to check permissions for project ${projectName}:`, error); - } - } - - return editableProjectNames; -}; - -const useProjectPermissions = (projects: any[]) => { - const queryKey = useMemo(() => { - if (!projects || projects.length === 0) { - return ['project-permissions', 'empty']; - } - - const projectFingerprint = projects.map((p) => ({ - name: p?.metadata?.name, - version: p?.metadata?.version, - updatedAt: p?.metadata?.updatedAt, - })); - - return ['project-permissions', JSON.stringify(projectFingerprint)]; - }, [projects]); - - const { - data: editableProjects = [], - isLoading: loading, - error, - } = useQuery({ - queryKey, - queryFn: () => checkProjectPermissions(projects), - enabled: !!projects && projects.length > 0, - staleTime: 5 * 60 * 1000, - refetchOnWindowFocus: true, - retry: 2, - onError: (error) => { - // eslint-disable-next-line no-console - console.warn('Failed to check project permissions:', error); - }, - }); - - const hasEditableProject = editableProjects.length > 0; - - return { editableProjects, hasEditableProject, loading, error }; -}; +import { useProjectPermissions } from './dashboard-permissions'; export const DashboardCreateDialog: React.FunctionComponent = () => { const { t } = useTranslation(process.env.I18N_NAMESPACE); diff --git a/web/src/components/dashboards/perses/dashboard-frame.tsx b/web/src/components/dashboards/perses/dashboard-frame.tsx index df33d350..f454345c 100644 --- a/web/src/components/dashboards/perses/dashboard-frame.tsx +++ b/web/src/components/dashboards/perses/dashboard-frame.tsx @@ -12,7 +12,7 @@ interface DashboardFrameProps { setActiveProject: (project: string | null) => void; activeProjectDashboardsMetadata: CombinedDashboardMetadata[]; changeBoard: (boardName: string) => void; - dashboardName: string; + dashboardDisplayName: string; children: ReactNode; } @@ -21,7 +21,7 @@ export const DashboardFrame: React.FC = ({ setActiveProject, activeProjectDashboardsMetadata, changeBoard, - dashboardName, + dashboardDisplayName, children, }) => { return ( @@ -36,7 +36,7 @@ export const DashboardFrame: React.FC = ({ {children} diff --git a/web/src/components/dashboards/perses/dashboard-header.tsx b/web/src/components/dashboards/perses/dashboard-header.tsx index dfe9e1e3..fc28a992 100644 --- a/web/src/components/dashboards/perses/dashboard-header.tsx +++ b/web/src/components/dashboards/perses/dashboard-header.tsx @@ -9,9 +9,7 @@ import { CombinedDashboardMetadata } from './hooks/useDashboardsData'; import { Breadcrumb, BreadcrumbItem } from '@patternfly/react-core'; import { useNavigate } from 'react-router-dom-v5-compat'; -import { StringParam, useQueryParam } from 'use-query-params'; import { getDashboardsListUrl, usePerspective } from '../../hooks/usePerspective'; -import { QueryParams } from '../../query-params'; import { chart_color_blue_100, @@ -31,11 +29,12 @@ const shouldHideFavoriteButton = (): boolean => { return currentUrl.includes(DASHBOARD_VIEW_PATH); }; -const DashboardBreadCrumb: React.FunctionComponent = () => { +const DashboardBreadCrumb: React.FunctionComponent<{ dashboardDisplayName?: string }> = ({ + dashboardDisplayName, +}) => { const { t } = useTranslation(process.env.I18N_NAMESPACE); const { perspective } = usePerspective(); - const [dashboardName] = useQueryParam(QueryParams.Dashboard, StringParam); const { theme } = usePatternFlyTheme(); const navigate = useNavigate(); @@ -63,26 +62,28 @@ const DashboardBreadCrumb: React.FunctionComponent = () => { > {t('Dashboards')} - {dashboardName && ( + {dashboardDisplayName && ( - {dashboardName} + {dashboardDisplayName} )} ); }; -const DashboardPageHeader: React.FunctionComponent = () => { +const DashboardPageHeader: React.FunctionComponent<{ dashboardDisplayName?: string }> = ({ + dashboardDisplayName, +}) => { const { t } = useTranslation(process.env.I18N_NAMESPACE); const hideFavBtn = shouldHideFavoriteButton(); return ( - + { type MonitoringDashboardsPageProps = PropsWithChildren<{ boardItems: CombinedDashboardMetadata[]; changeBoard: (dashboardName: string) => void; - dashboardName: string; + dashboardDisplayName: string; activeProject?: string; }>; -export const DashboardHeader: FC = memo(({ children }) => { - const { t } = useTranslation(process.env.I18N_NAMESPACE); - - return ( - <> - {t('Metrics dashboards')} - - - - {children} - - ); -}); +export const DashboardHeader: FC = memo( + ({ children, dashboardDisplayName }) => { + const { t } = useTranslation(process.env.I18N_NAMESPACE); + + return ( + <> + {t('Metrics dashboards')} + + + + {children} + + ); + }, +); export const DashboardListHeader: FC = memo(({ children }) => { const { t } = useTranslation(process.env.I18N_NAMESPACE); diff --git a/web/src/components/dashboards/perses/dashboard-list-frame.tsx b/web/src/components/dashboards/perses/dashboard-list-frame.tsx index 298bbb71..6bce52cc 100644 --- a/web/src/components/dashboards/perses/dashboard-list-frame.tsx +++ b/web/src/components/dashboards/perses/dashboard-list-frame.tsx @@ -26,7 +26,7 @@ export const DashboardListFrame: React.FC = ({ {children} diff --git a/web/src/components/dashboards/perses/dashboard-list.tsx b/web/src/components/dashboards/perses/dashboard-list.tsx index 3ce0ddbd..2154ab3e 100644 --- a/web/src/components/dashboards/perses/dashboard-list.tsx +++ b/web/src/components/dashboards/perses/dashboard-list.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, useMemo, type FC } from 'react'; +import React, { ReactNode, useCallback, useMemo, useState, type FC } from 'react'; import { useTranslation } from 'react-i18next'; import { useDashboardsData } from './hooks/useDashboardsData'; @@ -9,6 +9,7 @@ import { EmptyStateVariant, Pagination, Title, + Tooltip, } from '@patternfly/react-core'; import { DataView } from '@patternfly/react-data-view/dist/dynamic/DataView'; import { DataViewFilters } from '@patternfly/react-data-view/dist/dynamic/DataViewFilters'; @@ -21,18 +22,77 @@ import { DataViewTextFilter } from '@patternfly/react-data-view/dist/dynamic/Dat import { DataViewToolbar } from '@patternfly/react-data-view/dist/dynamic/DataViewToolbar'; import { useDataViewFilters, useDataViewSort } from '@patternfly/react-data-view'; import { useDataViewPagination } from '@patternfly/react-data-view/dist/dynamic/Hooks'; -import { ThProps } from '@patternfly/react-table'; +import { ActionsColumn, ThProps } from '@patternfly/react-table'; import { Link, useSearchParams } from 'react-router-dom-v5-compat'; import { getDashboardUrl, usePerspective } from '../../hooks/usePerspective'; import { Timestamp } from '@openshift-console/dynamic-plugin-sdk'; import { listPersesDashboardsDataTestIDs } from '../../../components/data-test'; import { DashboardListFrame } from './dashboard-list-frame'; +import { usePersesEditPermissions } from './dashboard-toolbar'; +import { DashboardResource } from '@perses-dev/core'; +import { + DeleteActionModal, + DuplicateActionModal, + RenameActionModal, +} from './dashboard-action-modals'; const perPageOptions = [ { title: '10', value: 10 }, { title: '20', value: 20 }, ]; +const DashboardActionsCell = React.memo( + ({ + project, + dashboard, + onRename, + onDuplicate, + onDelete, + emptyActions, + }: { + project: string; + dashboard: DashboardResource; + onRename: (dashboard: DashboardResource) => void; + onDuplicate: (dashboard: DashboardResource) => void; + onDelete: (dashboard: DashboardResource) => void; + emptyActions: any[]; + }) => { + const { t } = useTranslation(process.env.I18N_NAMESPACE); + const { canEdit, loading } = usePersesEditPermissions(project); + const disabled = !canEdit; + + const rowSpecificActions = useMemo( + () => [ + { + title: t('Rename dashboard'), + onClick: () => onRename(dashboard), + }, + { + title: t('Duplicate dashboard'), + onClick: () => onDuplicate(dashboard), + }, + { + title: t('Delete dashboard'), + onClick: () => onDelete(dashboard), + }, + ], + [dashboard, onRename, onDuplicate, onDelete, t], + ); + + if (disabled || loading) { + return ( + +
+ +
+
+ ); + } + + return ; + }, +); + interface DashboardRowNameLink { link: ReactNode; label: string; @@ -46,6 +106,8 @@ interface DashboardRow { // Raw values for sorting createdAt?: string; updatedAt?: string; + // Reference to original dashboard data + dashboard: DashboardResource; } interface DashboardRowFilters { @@ -87,14 +149,7 @@ const sortDashboardData = ( }; interface DashboardsTableProps { - persesDashboards: Array<{ - metadata?: { - name?: string; - project?: string; - createdAt?: string; - updatedAt?: string; - }; - }>; + persesDashboards: DashboardResource[]; persesDashboardsLoading: boolean; activeProject: string | null; } @@ -154,6 +209,7 @@ const DashboardsTable: React.FunctionComponent = ({ } return persesDashboards.map((board) => { const metadata = board?.metadata; + const displayName = board?.spec?.display?.name; const dashboardsParams = `?dashboard=${metadata?.name}&project=${metadata?.project}`; const dashboardName: DashboardRowNameLink = { link: ( @@ -161,10 +217,10 @@ const DashboardsTable: React.FunctionComponent = ({ to={`${dashboardBaseURL}${dashboardsParams}`} data-test={`perseslistpage-${board?.metadata?.name}`} > - {metadata?.name} + {displayName} ), - label: metadata?.name || '', + label: displayName || '', }; return { @@ -174,6 +230,7 @@ const DashboardsTable: React.FunctionComponent = ({ modified: , createdAt: metadata?.createdAt, updatedAt: metadata?.updatedAt, + dashboard: board, }; }); }, [dashboardBaseURL, persesDashboards, persesDashboardsLoading]); @@ -198,14 +255,83 @@ const DashboardsTable: React.FunctionComponent = ({ [filteredData, sortBy, direction], ); - const pageRows: DataViewTr[] = useMemo( - () => - sortedAndFilteredData - .slice((page - 1) * perPage, (page - 1) * perPage + perPage) - .map(({ name, project, created, modified }) => [name.link, project, created, modified]), - [page, perPage, sortedAndFilteredData], + const [targetedDashboard, setTargetedDashboard] = useState(); + const [isRenameModalOpen, setIsRenameModalOpen] = useState(false); + const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + const handleRenameModalOpen = useCallback((dashboard: DashboardResource) => { + setTargetedDashboard(dashboard); + setIsRenameModalOpen(true); + }, []); + + const handleRenameModalClose = useCallback(() => { + setIsRenameModalOpen(false); + setTargetedDashboard(undefined); + }, []); + + const handleDuplicateModalOpen = useCallback((dashboard: DashboardResource) => { + setTargetedDashboard(dashboard); + setIsDuplicateModalOpen(true); + }, []); + + const handleDuplicateModalClose = useCallback(() => { + setIsDuplicateModalOpen(false); + setTargetedDashboard(undefined); + }, []); + + const handleDeleteModalOpen = useCallback((dashboard: DashboardResource) => { + setTargetedDashboard(dashboard); + setIsDeleteModalOpen(true); + }, []); + + const handleDeleteModalClose = useCallback(() => { + setIsDeleteModalOpen(false); + setTargetedDashboard(undefined); + }, []); + + const emptyRowActions = useMemo( + () => [ + { + title: t("You don't have permissions to dashboard actions"), + onClick: () => {}, + }, + ], + [t], ); + const pageRows: DataViewTr[] = useMemo(() => { + return sortedAndFilteredData + .slice((page - 1) * perPage, (page - 1) * perPage + perPage) + .map(({ name, project, created, modified, dashboard }) => [ + name.link, + project, + created, + modified, + { + cell: ( + + ), + props: { isActionCell: true }, + }, + ]); + }, [ + sortedAndFilteredData, + page, + perPage, + emptyRowActions, + handleRenameModalOpen, + handleDuplicateModalOpen, + handleDeleteModalOpen, + ]); + const PaginationTool = () => { return ( = ({ } /> {hasData ? ( - + <> + + + + + ) : ( { setActiveProject(urlProject); } - // Change dashboard if provided in URL - if (urlDashboard && urlDashboard !== dashboardName) { - changeBoard(urlDashboard); - } + useEffect(() => { + if (urlDashboard && urlDashboard !== dashboardName) { + changeBoard(urlDashboard); + } + }, [urlDashboard, dashboardName, changeBoard]); if (combinedInitialLoad) { return <LoadingInline />; @@ -81,7 +82,7 @@ const DashboardPage_: FC = () => { setActiveProject={setActiveProject} activeProjectDashboardsMetadata={activeProjectDashboardsMetadata} changeBoard={changeBoard} - dashboardName={currentDashboard.name} + dashboardDisplayName={currentDashboard.title} > <OCPDashboardApp dashboardResource={currentDashboard.persesDashboard} diff --git a/web/src/components/dashboards/perses/dashboard-permissions.ts b/web/src/components/dashboards/perses/dashboard-permissions.ts new file mode 100644 index 00000000..df5688a0 --- /dev/null +++ b/web/src/components/dashboards/perses/dashboard-permissions.ts @@ -0,0 +1,92 @@ +import { checkAccess } from '@openshift-console/dynamic-plugin-sdk'; +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; + +const checkProjectPermissions = async (projects: any[]): Promise<string[]> => { + if (!projects || projects.length === 0) { + return []; + } + + const editableProjectNames: string[] = []; + + for (const project of projects) { + const projectName = project?.metadata?.name; + if (!projectName) continue; + + try { + const [createResult, updateResult, deleteResult] = await Promise.all([ + checkAccess({ + group: 'perses.dev', + resource: 'persesdashboards', + verb: 'create', + namespace: projectName, + }), + checkAccess({ + group: 'perses.dev', + resource: 'persesdashboards', + verb: 'update', + namespace: projectName, + }), + checkAccess({ + group: 'perses.dev', + resource: 'persesdashboards', + verb: 'delete', + namespace: projectName, + }), + ]); + + const canEdit = + createResult.status.allowed && updateResult.status.allowed && deleteResult.status.allowed; + + if (canEdit) { + editableProjectNames.push(projectName); + } + } catch (error) { + // eslint-disable-next-line no-console + console.warn(`Failed to check permissions for project ${projectName}:`, error); + } + } + + return editableProjectNames; +}; + +export const useProjectPermissions = (projects: any[]) => { + const queryKey = useMemo(() => { + if (!projects || projects.length === 0) { + return ['project-permissions', 'empty']; + } + + const projectFingerprint = projects.map((p) => ({ + name: p?.metadata?.name, + version: p?.metadata?.version, + updatedAt: p?.metadata?.updatedAt, + })); + + return ['project-permissions', JSON.stringify(projectFingerprint)]; + }, [projects]); + + const { + data: editableProjects = [], + isLoading: loading, + error, + } = useQuery({ + queryKey, + queryFn: async () => { + try { + return await checkProjectPermissions(projects); + } catch (error) { + // eslint-disable-next-line no-console + console.warn('Failed to check project permissions:', error); + return []; + } + }, + enabled: !!projects && projects.length > 0, + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: true, + retry: 2, + }); + + const hasEditableProject = editableProjects.length > 0; + + return { editableProjects, hasEditableProject, loading, error }; +}; diff --git a/web/src/components/dashboards/perses/dashboard-utils.ts b/web/src/components/dashboards/perses/dashboard-utils.ts index b7727cb0..2a8101ae 100644 --- a/web/src/components/dashboards/perses/dashboard-utils.ts +++ b/web/src/components/dashboards/perses/dashboard-utils.ts @@ -1,5 +1,9 @@ import { DashboardResource } from '@perses-dev/core'; +/** + * Generated a resource name valid for the API. + * By removing accents from alpha characters and replace specials character by underscores. + */ export const generateMetadataName = (name: string): string => { return name .normalize('NFD') diff --git a/web/src/components/dashboards/perses/hooks/useDashboardsData.ts b/web/src/components/dashboards/perses/hooks/useDashboardsData.ts index 7fc780e7..614093bb 100644 --- a/web/src/components/dashboards/perses/hooks/useDashboardsData.ts +++ b/web/src/components/dashboards/perses/hooks/useDashboardsData.ts @@ -1,4 +1,4 @@ -import { useMemo, useCallback } from 'react'; +import { useMemo, useCallback, useRef } from 'react'; import { DashboardResource } from '@perses-dev/core'; import { useNavigate } from 'react-router-dom-v5-compat'; @@ -39,13 +39,33 @@ export const useDashboardsData = () => { return true; }, [persesProjectsLoading, persesDashboardsLoading, initialPageLoad, setInitialPageLoadFalse]); + const prevDashboardsRef = useRef<DashboardResource[]>([]); + const prevMetadataRef = useRef<CombinedDashboardMetadata[]>([]); + // Homogenize data needed for dashboards dropdown between legacy and perses dashboards // to enable both to use the same component const combinedDashboardsMetadata = useMemo<CombinedDashboardMetadata[]>(() => { if (combinedInitialLoad) { return []; } - return persesDashboards.map((persesDashboard) => { + + // Check if dashboards data has actually changed to avoid recreation + const dashboardsChanged = + persesDashboards.length !== prevDashboardsRef.current.length || + persesDashboards.some((dashboard, i) => { + const prevDashboard = prevDashboardsRef.current[i]; + return ( + dashboard?.metadata?.name !== prevDashboard?.metadata?.name || + dashboard?.spec?.display?.name !== prevDashboard?.spec?.display?.name || + dashboard?.metadata?.project !== prevDashboard?.metadata?.project + ); + }); + + if (!dashboardsChanged && prevMetadataRef.current.length > 0) { + return prevMetadataRef.current; + } + + const newMetadata = persesDashboards.map((persesDashboard) => { const name = persesDashboard?.metadata?.name; const displayName = persesDashboard?.spec?.display?.name || name; @@ -57,6 +77,10 @@ export const useDashboardsData = () => { persesDashboard, }; }); + + prevDashboardsRef.current = persesDashboards; + prevMetadataRef.current = newMetadata; + return newMetadata; }, [persesDashboards, combinedInitialLoad]); // Retrieve dashboard metadata for the currently selected project diff --git a/web/src/components/dashboards/perses/hooks/usePerses.ts b/web/src/components/dashboards/perses/hooks/usePerses.ts index 728e13e3..cc3f0f10 100644 --- a/web/src/components/dashboards/perses/hooks/usePerses.ts +++ b/web/src/components/dashboards/perses/hooks/usePerses.ts @@ -29,7 +29,7 @@ export const usePerses = (project?: string | number) => { } = useQuery({ queryKey: ['dashboards'], queryFn: fetchPersesDashboardsMetadata, - enabled: true, + enabled: !project, // Only fetch all dashboards when no specific project is requested refetchInterval: refreshInterval, }); @@ -50,10 +50,11 @@ export const usePerses = (project?: string | number) => { }); return { - // All Dashboards - persesDashboards: persesDashboards ?? [], - persesDashboardsError, - persesDashboardsLoading, + // All Dashboards - fallback to project dashboards when all dashboards query is disabled + persesDashboards: persesDashboards ?? persesProjectDashboards ?? [], + persesDashboardsError: persesDashboardsError ?? persesProjectDashboardsError, + persesDashboardsLoading: + persesDashboardsLoading || (!!project && persesProjectDashboardsLoading), // All Projects persesProjectsLoading, persesProjects: persesProjects ?? [],