From ffeabf0384bdd069faa7a0055605d52e46a1988d Mon Sep 17 00:00:00 2001 From: Jenny Zhu Date: Mon, 2 Feb 2026 19:08:12 -0500 Subject: [PATCH 1/4] feat: OU-1138 DashboardList Actions Delete, Duplicate, Rename Dashboards --- .../perses/dashboard-action-modals.tsx | 552 ++++++++++++++++++ .../perses/dashboard-action-validations.ts | 78 +++ .../dashboards/perses/dashboard-api.ts | 67 +++ .../perses/dashboard-create-dialog.tsx | 88 +-- .../dashboards/perses/dashboard-frame.tsx | 6 +- .../dashboards/perses/dashboard-header.tsx | 47 +- .../perses/dashboard-list-frame.tsx | 2 +- .../dashboards/perses/dashboard-list.tsx | 194 +++++- .../dashboards/perses/dashboard-page.tsx | 13 +- .../perses/dashboard-permissions.ts | 92 +++ .../dashboards/perses/dashboard-utils.ts | 4 + .../perses/hooks/useDashboardsData.ts | 28 +- .../dashboards/perses/hooks/usePerses.ts | 11 +- 13 files changed, 1032 insertions(+), 150 deletions(-) create mode 100644 web/src/components/dashboards/perses/dashboard-action-modals.tsx create mode 100644 web/src/components/dashboards/perses/dashboard-action-validations.ts create mode 100644 web/src/components/dashboards/perses/dashboard-permissions.ts 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..c3b2535b --- /dev/null +++ b/web/src/components/dashboards/perses/dashboard-action-modals.tsx @@ -0,0 +1,552 @@ +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 } from '@patternfly/react-tokens'; +import { useNavigate } from 'react-router-dom-v5-compat'; +import { usePerspective, getDashboardUrl } from '../../hooks/usePerspective'; + +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 formGroupStyle = { + '--pf-v6-c-form__label-text--FontWeight': 'bold', + } as React.CSSProperties; + + const form = useForm({ + resolver: zodResolver(renameDashboardDialogValidationSchema), + 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, loading: permissionsLoading } = 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); + + const form = useForm({ + resolver: validationSchema + ? zodResolver(validationSchema) + : zodResolver(createDashboardDialogValidationSchema), + 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 || 'Select project'; + }, [filteredProjects, selectedProjectName]); + + if (!dashboard) { + return null; + } + + if (permissionsLoading || persesProjects.length === 0) { + return ( + + + + {t('Loading projects...')} + + + ); + } + + if (filteredProjects.length === 0) { + return ( + + + +

{t('You do not have permission to create dashboards in any projects.')}

+
+ + + +
+ ); + } + + 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); + } + }; + + const formGroupStyle = { + '--pf-v6-c-form__label-text--FontWeight': 'bold', + } as React.CSSProperties; + + 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..d0f33462 --- /dev/null +++ b/web/src/components/dashboards/perses/dashboard-action-validations.ts @@ -0,0 +1,78 @@ +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 dashboardDisplayNameValidationSchema = z + .string() + .min(1, 'Required') + .max(75, 'Must be 75 or fewer characters long'); + +export const createDashboardDialogValidationSchema = z.object({ + projectName: nameSchema, + dashboardName: dashboardDisplayNameValidationSchema, +}); +export type CreateDashboardValidationType = z.infer; + +export const renameDashboardDialogValidationSchema = z.object({ + dashboardName: dashboardDisplayNameValidationSchema, +}); +export type RenameDashboardValidationType = z.infer; + +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): 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, + isSchemaLoading: true, + hasSchemaError: false, + }; + + const refinedSchema = createDashboardDialogValidationSchema.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: `Dashboard name '${schema.dashboardName}' already exists in '${schema.projectName}' project!`, + path: ['dashboardName'], + }), + ); + + return { schema: refinedSchema, isSchemaLoading: true, hasSchemaError: false }; + }, [dashboards, isDashboardsLoading, isError]); +} 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 ?? [], From a5071b07c65eb01ff1c132e8bdf7b486806d1f9d Mon Sep 17 00:00:00 2001 From: Jenny Zhu <jenny.a.zhu@gmail.com> Date: Tue, 3 Feb 2026 14:27:52 -0500 Subject: [PATCH 2/4] fix: OU-1138 Add translations, add new direct dependency "react-hook-form", remove unncessary logic is Action Modals --- .../perses/dashboard-action-modals.tsx | 48 +++++------------ .../perses/dashboard-action-validations.ts | 53 ++++++++++++------- 2 files changed, 49 insertions(+), 52 deletions(-) diff --git a/web/src/components/dashboards/perses/dashboard-action-modals.tsx b/web/src/components/dashboards/perses/dashboard-action-modals.tsx index c3b2535b..0c2baa55 100644 --- a/web/src/components/dashboards/perses/dashboard-action-modals.tsx +++ b/web/src/components/dashboards/perses/dashboard-action-modals.tsx @@ -49,10 +49,14 @@ import { useToast } from './ToastProvider'; import { usePerses } from './hooks/usePerses'; import { generateMetadataName } from './dashboard-utils'; import { useProjectPermissions } from './dashboard-permissions'; -import { t_global_spacer_200 } from '@patternfly/react-tokens'; +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 <div style={{ paddingBottom: t_global_spacer_200.value }} />; }; @@ -68,12 +72,8 @@ export const RenameActionModal = ({ dashboard, isOpen, onClose }: ActionModalPro const { t } = useTranslation(process.env.I18N_NAMESPACE); const { addAlert } = useToast(); - const formGroupStyle = { - '--pf-v6-c-form__label-text--FontWeight': 'bold', - } as React.CSSProperties; - const form = useForm<RenameDashboardValidationType>({ - resolver: zodResolver(renameDashboardDialogValidationSchema), + resolver: zodResolver(renameDashboardDialogValidationSchema(t)), mode: 'onBlur', defaultValues: { dashboardName: dashboard ? getResourceDisplayName(dashboard) : '' }, }); @@ -122,7 +122,7 @@ export const RenameActionModal = ({ dashboard, isOpen, onClose }: ActionModalPro ouiaId="RenameModal" aria-labelledby="rename-modal" > - <ModalHeader title="Rename Dashboard" labelId="rename-modal-title" /> + <ModalHeader title={t('Rename Dashboard')} labelId="rename-modal-title" /> <FormProvider {...form}> <form onSubmit={form.handleSubmit(processForm)}> <ModalBody id="rename-modal-box"> @@ -197,7 +197,7 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal return persesProjects || []; }, [persesProjects]); - const { editableProjects, loading: permissionsLoading } = useProjectPermissions(hookInput); + const { editableProjects } = useProjectPermissions(hookInput); const filteredProjects = useMemo(() => { return persesProjects.filter((project) => editableProjects.includes(project.metadata.name)); @@ -213,12 +213,12 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal return filteredProjects[0]?.metadata.name || ''; }, [dashboard, editableProjects, filteredProjects]); - const { schema: validationSchema } = useDashboardValidationSchema(defaultProject); + const { schema: validationSchema } = useDashboardValidationSchema(defaultProject, t); const form = useForm<CreateDashboardValidationType>({ resolver: validationSchema ? zodResolver(validationSchema) - : zodResolver(createDashboardDialogValidationSchema), + : zodResolver(createDashboardDialogValidationSchema(t)), mode: 'onBlur', defaultValues: { projectName: defaultProject, @@ -242,29 +242,13 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal const selectedProject = filteredProjects.find((p) => p.metadata.name === selectedProjectName); return selectedProject ? getResourceDisplayName(selectedProject) - : selectedProjectName || 'Select project'; - }, [filteredProjects, selectedProjectName]); + : selectedProjectName || t('Select project'); + }, [filteredProjects, selectedProjectName, t]); if (!dashboard) { return null; } - if (permissionsLoading || persesProjects.length === 0) { - return ( - <Modal - variant={ModalVariant.small} - isOpen={isOpen} - onClose={onClose} - aria-labelledby="duplicate-modal-title" - > - <ModalHeader title="Duplicate Dashboard" labelId="duplicate-modal-title" /> - <ModalBody style={{ textAlign: 'center', padding: '2rem' }}> - {t('Loading projects...')} - </ModalBody> - </Modal> - ); - } - if (filteredProjects.length === 0) { return ( <Modal @@ -273,7 +257,7 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal onClose={onClose} aria-labelledby="duplicate-modal-title" > - <ModalHeader title="Duplicate Dashboard" labelId="duplicate-modal-title" /> + <ModalHeader title={t('Duplicate Dashboard')} labelId="duplicate-modal-title" /> <ModalBody> <p>{t('You do not have permission to create dashboards in any projects.')}</p> </ModalBody> @@ -346,10 +330,6 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal } }; - const formGroupStyle = { - '--pf-v6-c-form__label-text--FontWeight': 'bold', - } as React.CSSProperties; - return ( <Modal variant={ModalVariant.small} @@ -526,7 +506,7 @@ export const DeleteActionModal = ({ dashboard, isOpen, onClose }: ActionModalPro > <ModalHeader titleIconVariant="warning" - title="Permanently delete dashboard?" + title={t('Permanently delete dashboard?')} labelId="delete-modal-title" /> <ModalBody id="delete-modal-box-body"> diff --git a/web/src/components/dashboards/perses/dashboard-action-validations.ts b/web/src/components/dashboards/perses/dashboard-action-validations.ts index d0f33462..50c169dc 100644 --- a/web/src/components/dashboards/perses/dashboard-action-validations.ts +++ b/web/src/components/dashboards/perses/dashboard-action-validations.ts @@ -4,21 +4,29 @@ import { nameSchema } from '@perses-dev/core'; import { useDashboardList } from './dashboard-api'; import { generateMetadataName } from './dashboard-utils'; -export const dashboardDisplayNameValidationSchema = z - .string() - .min(1, 'Required') - .max(75, 'Must be 75 or fewer characters long'); +export const createDashboardDisplayNameValidationSchema = (t?: (key: string) => string) => + z + .string() + .min(1, t ? t('Required') : 'Required') + .max(75, t ? t('Must be 75 or fewer characters long') : 'Must be 75 or fewer characters long'); -export const createDashboardDialogValidationSchema = z.object({ - projectName: nameSchema, - dashboardName: dashboardDisplayNameValidationSchema, -}); -export type CreateDashboardValidationType = z.infer<typeof createDashboardDialogValidationSchema>; +export const createDashboardDialogValidationSchema = (t?: (key: string) => string) => + z.object({ + projectName: nameSchema, + dashboardName: createDashboardDisplayNameValidationSchema(t), + }); -export const renameDashboardDialogValidationSchema = z.object({ - dashboardName: dashboardDisplayNameValidationSchema, -}); -export type RenameDashboardValidationType = z.infer<typeof renameDashboardDialogValidationSchema>; +export const renameDashboardDialogValidationSchema = (t?: (key: string) => string) => + z.object({ + dashboardName: createDashboardDisplayNameValidationSchema(t), + }); + +export type CreateDashboardValidationType = z.infer< + ReturnType<typeof createDashboardDialogValidationSchema> +>; +export type RenameDashboardValidationType = z.infer< + ReturnType<typeof renameDashboardDialogValidationSchema> +>; export interface DashboardValidationSchema { schema?: z.ZodSchema; @@ -27,7 +35,10 @@ export interface DashboardValidationSchema { } // Validate dashboard name and check if it doesn't already exist -export function useDashboardValidationSchema(projectName?: string): DashboardValidationSchema { +export function useDashboardValidationSchema( + projectName?: string, + t?: (key: string, options?: any) => string, +): DashboardValidationSchema { const { data: dashboards, isLoading: isDashboardsLoading, @@ -51,12 +62,12 @@ export function useDashboardValidationSchema(projectName?: string): DashboardVal if (!dashboards?.length) return { - schema: createDashboardDialogValidationSchema, + schema: createDashboardDialogValidationSchema(t), isSchemaLoading: true, hasSchemaError: false, }; - const refinedSchema = createDashboardDialogValidationSchema.refine( + const refinedSchema = createDashboardDialogValidationSchema(t).refine( (schema) => { return !(dashboards ?? []).some((dashboard) => { return ( @@ -68,11 +79,17 @@ export function useDashboardValidationSchema(projectName?: string): DashboardVal }, (schema) => ({ // eslint-disable-next-line max-len - message: `Dashboard name '${schema.dashboardName}' already exists in '${schema.projectName}' project!`, + 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]); + }, [dashboards, isDashboardsLoading, isError, t]); } From 47d2b6015f794a0687249ad21134bf54bea9eeab Mon Sep 17 00:00:00 2001 From: Jenny Zhu <jenny.a.zhu@gmail.com> Date: Tue, 3 Feb 2026 14:31:07 -0500 Subject: [PATCH 3/4] fix: OU-1138 Update package.json with new dependency --- web/package.json | 1 + 1 file changed, 1 insertion(+) 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", From 7d692e0647657b33e057e6e730b9e86edac3e78a Mon Sep 17 00:00:00 2001 From: Jenny Zhu <jenny.a.zhu@gmail.com> Date: Wed, 4 Feb 2026 10:24:33 -0500 Subject: [PATCH 4/4] fix: OU-1138 Remove unnecessary logic checks and update package-lock.json --- web/package-lock.json | 1 + .../perses/dashboard-action-modals.tsx | 21 ------------------- .../perses/dashboard-action-validations.ts | 7 ++----- 3 files changed, 3 insertions(+), 26 deletions(-) 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/src/components/dashboards/perses/dashboard-action-modals.tsx b/web/src/components/dashboards/perses/dashboard-action-modals.tsx index 0c2baa55..8d7c5405 100644 --- a/web/src/components/dashboards/perses/dashboard-action-modals.tsx +++ b/web/src/components/dashboards/perses/dashboard-action-modals.tsx @@ -249,27 +249,6 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal return null; } - if (filteredProjects.length === 0) { - return ( - <Modal - variant={ModalVariant.small} - isOpen={isOpen} - onClose={onClose} - aria-labelledby="duplicate-modal-title" - > - <ModalHeader title={t('Duplicate Dashboard')} labelId="duplicate-modal-title" /> - <ModalBody> - <p>{t('You do not have permission to create dashboards in any projects.')}</p> - </ModalBody> - <ModalFooter> - <Button key="close" variant="primary" onClick={onClose}> - {t('Close')} - </Button> - </ModalFooter> - </Modal> - ); - } - const processForm: SubmitHandler<CreateDashboardValidationType> = (data) => { const newDashboard: DashboardResource = { ...dashboard, diff --git a/web/src/components/dashboards/perses/dashboard-action-validations.ts b/web/src/components/dashboards/perses/dashboard-action-validations.ts index 50c169dc..907362c6 100644 --- a/web/src/components/dashboards/perses/dashboard-action-validations.ts +++ b/web/src/components/dashboards/perses/dashboard-action-validations.ts @@ -4,11 +4,8 @@ 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 ? t('Required') : 'Required') - .max(75, t ? t('Must be 75 or fewer characters long') : 'Must be 75 or fewer characters long'); +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({