From 0db0fd03188d1cf5fdd1cfa8c9f23d5b405ad8e9 Mon Sep 17 00:00:00 2001 From: PeterYurkovich Date: Mon, 11 May 2026 17:02:28 -0400 Subject: [PATCH 1/4] feat: update mcp for 4.22 --- AGENTS.md | 1 - scripts/deploy-acm.sh | 28 +- web/cypress/CYPRESS_TESTING_GUIDE.md | 2 - web/eslint.config.ts | 15 + web/locales/en/plugin__monitoring-plugin.json | 11 +- .../Incidents/IncidentAlertStateIcon.tsx | 6 +- .../IncidentsChart/IncidentsChart.tsx | 4 +- .../components/Incidents/IncidentsPage.tsx | 4 +- .../Incidents/ToolbarItemFilter.tsx | 8 +- web/src/components/Incidents/utils.ts | 3 +- web/src/components/MetricsPage.tsx | 10 +- web/src/components/TypeaheadSelect.tsx | 8 +- .../AlertList/AggregateAlertTableRow.tsx | 8 - .../alerting/AlertList/LabelFilter.tsx | 4 +- web/src/components/alerting/AlertsPage.tsx | 5 +- .../console/utils/ref-width-hook.ts | 4 +- .../utils/single-typeahead-dropdown.tsx | 26 +- .../dashboards/perses/PersesWrapper.tsx | 8 +- .../dashboards/perses/ToastProvider.tsx | 4 +- .../perses/dashboard-action-modals.tsx | 16 +- .../perses/dashboard-action-validations.ts | 37 +- .../perses/dashboard-actions-menu.tsx | 6 +- .../perses/dashboard-create-dialog.tsx | 343 +++++++----------- .../perses/dashboard-dialog-helpers.tsx | 213 +++++++++++ .../dashboards/perses/dashboard-frame.tsx | 4 +- .../dashboards/perses/dashboard-header.tsx | 12 +- .../perses/dashboard-import-dialog.tsx | 327 ++++++++--------- .../perses/dashboard-list-frame.tsx | 4 +- .../dashboards/perses/dashboard-list.tsx | 6 +- .../dashboards/perses/dashboard-page.tsx | 2 +- .../dashboards/perses/datasource-api.ts | 6 +- .../perses/perses/datasource-cache-api.ts | 2 +- .../perses/project/ProjectDropdown.tsx | 39 +- web/src/components/data-test.ts | 1 + .../components/ols-tool-ui/ShowTimeseries.tsx | 4 +- .../helpers/AddToDashboardButton.tsx | 6 +- .../helpers/OlsToolUIPersesWrapper.tsx | 6 +- web/src/components/table/TableFilters.tsx | 4 +- web/src/components/table/TableToolbar.tsx | 10 +- web/src/components/table/useTableColumns.ts | 4 +- .../components/table/useTablePagination.ts | 6 +- web/src/components/targets-page.tsx | 2 +- web/src/contexts/MonitoringContext.tsx | 6 +- 43 files changed, 685 insertions(+), 540 deletions(-) create mode 100644 web/src/components/dashboards/perses/dashboard-dialog-helpers.tsx diff --git a/AGENTS.md b/AGENTS.md index 16917c5bc..f1298f843 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -320,7 +320,6 @@ npx cypress run --component --spec cypress/component/labels.cy.tsx Component test files use the `.cy.tsx` extension and go in `web/cypress/component/`: ```typescript -import React from 'react'; import { MyComponent } from '../../src/components/MyComponent'; describe('MyComponent', () => { diff --git a/scripts/deploy-acm.sh b/scripts/deploy-acm.sh index 27b92b5f3..32d8773cc 100755 --- a/scripts/deploy-acm.sh +++ b/scripts/deploy-acm.sh @@ -1,29 +1,5 @@ #!/usr/bin/env bash -# Terminal output colors -YELLOW='\033[0;33m' -ENDCOLOR='\033[0m' # No Color -RED='\033[0;31m' - -if ! [ -x "$(command -v yq)" ]; then - printf "${RED}yq required to run make deploy-acm ${ENDCOLOR}\n" - exit 1 -fi - -if [[ "$OSTYPE" == "darwin"* ]] -then - # due to mac limitation with installing packages inside of dockerfiles, builds of the frontend must be done outside the dockerfile. - printf "${YELLOW}Enabling ACM plugin-name ${ENDCOLOR}\n" - yq -i '.plugin.features.acm.enabled = true' charts/openshift-console-plugin/values.yaml -fi - -export DOCKER_FILE_NAME=Dockerfile.mcp +export DOCKER_FILE_NAME=Dockerfile.dev-mcp +export REPO=monitoring-console-plugin make deploy - -if [[ "$OSTYPE" == "darwin"* ]] -then - # rollback changes - printf "${YELLOW}Disabling ACM features${ENDCOLOR}\n" - yq -i '.plugin.features.acm.enabled = false' charts/openshift-console-plugin/values.yaml - osascript -e 'display notification "Plugin Deployed" with title "Monitoring Plugin"' -fi diff --git a/web/cypress/CYPRESS_TESTING_GUIDE.md b/web/cypress/CYPRESS_TESTING_GUIDE.md index 071d03f80..cf43e21c5 100644 --- a/web/cypress/CYPRESS_TESTING_GUIDE.md +++ b/web/cypress/CYPRESS_TESTING_GUIDE.md @@ -113,7 +113,6 @@ Component tests mount individual React components in isolation using Cypress, wi Component test files use the `.cy.tsx` extension and live in `cypress/component/`: ```typescript -import React from 'react'; import { Labels } from '../../src/components/labels'; describe('Labels', () => { @@ -313,4 +312,3 @@ Cypress tests run automatically in the CI pipeline: - **Test Scenarios Catalog**: `E2E_TEST_SCENARIOS.md` - **Setup Instructions**: `README.md` - **Main Guide**: `../../AGENTS.md` - diff --git a/web/eslint.config.ts b/web/eslint.config.ts index 1b8fb996b..40bcec9a2 100644 --- a/web/eslint.config.ts +++ b/web/eslint.config.ts @@ -79,6 +79,21 @@ export default defineConfig([ 'react-hooks/incompatible-library': 'off', '@typescript-eslint/no-explicit-any': 'off', 'react-hooks/refs': 'off', + + // Prevent directly importing react as a lint rule + 'no-restricted-syntax': [ + 'error', + { + selector: 'ImportDeclaration[source.value="react"] ImportDefaultSpecifier', + message: + 'Do not directly import React. Add specific named imports instead (`import { useState, FC } from "react"`).', + }, + { + selector: 'ImportDeclaration[source.value="react"] ImportNamespaceSpecifier', + message: + 'Do not directly namespace import React (`import * as React`). Add specific named imports instead (`import { useState, FC } from "react"`).', + }, + ], }, }, ]); diff --git a/web/locales/en/plugin__monitoring-plugin.json b/web/locales/en/plugin__monitoring-plugin.json index 845790b01..9e0e42f18 100644 --- a/web/locales/en/plugin__monitoring-plugin.json +++ b/web/locales/en/plugin__monitoring-plugin.json @@ -16,7 +16,6 @@ "Severity": "Severity", "Namespace": "Namespace", "Source": "Source", - "Cluster": "Cluster", "Silence alert": "Silence alert", "User": "User", "Platform": "Platform", @@ -62,6 +61,7 @@ "The alert is now silenced for a defined time period. Silences temporarily mute alerts based on a set of label selectors that you define. Notifications will not be sent for alerts that match all the listed values or regular expressions.": "The alert is now silenced for a defined time period. Silences temporarily mute alerts based on a set of label selectors that you define. Notifications will not be sent for alerts that match all the listed values or regular expressions.", "Alert Name": "Alert Name", "Total": "Total", + "Cluster": "Cluster", "Filter by Cluster": "Filter by Cluster", "No alerts found": "No alerts found", "Error loading silences from Alertmanager. Some of the alerts below may actually be silenced.": "Error loading silences from Alertmanager. Some of the alerts below may actually be silenced.", @@ -203,15 +203,9 @@ "Dashboard actions": "Dashboard actions", "Import": "Import", "To create dashboards, contact your cluster administrator for permission.": "To create dashboards, contact your cluster administrator for permission.", - "Project is required": "Project is required", - "Dashboard name is required": "Dashboard name is required", - "Failed to create dashboard. Please try again.": "Failed to create dashboard. Please try again.", "Create Dashboard": "Create Dashboard", - "Select project": "Select project", - "Select a project": "Select a project", - "No project found for \"{{filter}}\"": "No project found for \"{{filter}}\"", "my-new-dashboard": "my-new-dashboard", - "Creating...": "Creating...", + "Select project": "Select 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}}", @@ -222,6 +216,7 @@ "Failed to import dashboard. Please try again.": "Failed to import dashboard. Please try again.", "Error importing dashboard: {{error}}": "Error importing dashboard: {{error}}", "Migration failed. Please try again.": "Migration failed. Please try again.", + "Error migrating dashboard: {{error}}": "Error migrating dashboard: {{error}}", "Import Dashboard": "Import Dashboard", "1. Provide a dashboard (JSON or YAML)": "1. Provide a dashboard (JSON or YAML)", "Upload a dashboard file or paste the dashboard definition directly in the editor below.": "Upload a dashboard file or paste the dashboard definition directly in the editor below.", diff --git a/web/src/components/Incidents/IncidentAlertStateIcon.tsx b/web/src/components/Incidents/IncidentAlertStateIcon.tsx index 87f6a1636..37f37354c 100644 --- a/web/src/components/Incidents/IncidentAlertStateIcon.tsx +++ b/web/src/components/Incidents/IncidentAlertStateIcon.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import { FC } from 'react'; import { Tooltip } from '@patternfly/react-core'; import { BellIcon, CheckIcon, BellSlashIcon } from '@patternfly/react-icons'; import { useTranslation } from 'react-i18next'; @@ -20,7 +20,7 @@ const getAlertState = (alertDetails: IncidentsDetailsAlert): 'firing' | 'resolve return 'firing'; }; -export const IncidentAlertStateIcon: React.FC = ({ +export const IncidentAlertStateIcon: FC = ({ alertDetails, showTooltip = true, }) => { @@ -75,7 +75,7 @@ const getGroupedAlertState = (groupedAlert: GroupedAlert): 'firing' | 'resolved' return 'firing'; }; -export const GroupedAlertStateIcon: React.FC = ({ +export const GroupedAlertStateIcon: FC = ({ groupedAlert, showTooltip = true, }) => { diff --git a/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx b/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx index 51c7a52a8..dfe4ba70e 100644 --- a/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx +++ b/web/src/components/Incidents/IncidentsChart/IncidentsChart.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { Chart, @@ -282,4 +282,4 @@ const IncidentsChart = ({ ); }; -export default React.memo(IncidentsChart); +export default memo(IncidentsChart); diff --git a/web/src/components/Incidents/IncidentsPage.tsx b/web/src/components/Incidents/IncidentsPage.tsx index c093365df..027c47406 100644 --- a/web/src/components/Incidents/IncidentsPage.tsx +++ b/web/src/components/Incidents/IncidentsPage.tsx @@ -1,5 +1,5 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import { useMemo, useState, useEffect, useCallback } from 'react'; +import { useMemo, useState, useEffect, useCallback, MouseEvent } from 'react'; import { useSafeFetch } from '../console/utils/safe-fetch-hook'; import { createAlertsQuery, fetchDataForIncidentsAndAlerts } from './api'; import { useTranslation } from 'react-i18next'; @@ -110,7 +110,7 @@ const IncidentsPage = () => { }); const onFilterToggle = ( - ev: React.MouseEvent, + ev: MouseEvent, filterName: keyof IncidentsPageFiltersExpandedState | 'filterType', setter, ) => { diff --git a/web/src/components/Incidents/ToolbarItemFilter.tsx b/web/src/components/Incidents/ToolbarItemFilter.tsx index ebb364851..78c853e89 100644 --- a/web/src/components/Incidents/ToolbarItemFilter.tsx +++ b/web/src/components/Incidents/ToolbarItemFilter.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import { ChangeEvent, FC, MouseEvent, MouseEventHandler } from 'react'; import { ToolbarItem, ToolbarFilter, @@ -32,19 +32,19 @@ interface IncidentFilterToolbarItemProps { onDeleteGroupIncidentFilterChip: (activeFilters: any, dispatch: any, category: any) => void; incidentFilterIsExpanded: boolean; onIncidentFiltersSelect: ( - event: React.MouseEvent | React.ChangeEvent | undefined, + event: MouseEvent | ChangeEvent | undefined, selection: any, dispatch: any, activeFilters: any, categoryFilterType: string, ) => void; setIncidentIsExpanded: (isOpen: boolean) => void; - onIncidentFilterToggle: React.MouseEventHandler; + onIncidentFilterToggle: MouseEventHandler; dispatch: any; showToolbarItem?: boolean; } -const IncidentFilterToolbarItem: React.FC = ({ +const IncidentFilterToolbarItem: FC = ({ categoryName, toggleLabel, options, diff --git a/web/src/components/Incidents/utils.ts b/web/src/components/Incidents/utils.ts index 3b12d5b2c..b71a1210e 100644 --- a/web/src/components/Incidents/utils.ts +++ b/web/src/components/Incidents/utils.ts @@ -17,6 +17,7 @@ import { SpanDates, Timestamps, } from './model'; +import { TFunction } from 'i18next'; /** * The Prometheus query step interval in seconds. @@ -884,7 +885,7 @@ export const parseUrlParams = (search) => { * @returns {{value: string}[]} An array of objects, where each object has a * `value` key with a unique incident ID. */ -export const getIncidentIdOptions = (incidents: Array, t: (key: string) => string) => { +export const getIncidentIdOptions = (incidents: Array, t: TFunction) => { const incidentMap = new Map(); incidents.forEach((incident) => { if (incident.group_id) { diff --git a/web/src/components/MetricsPage.tsx b/web/src/components/MetricsPage.tsx index 4a74ba84a..cf69a21f7 100644 --- a/web/src/components/MetricsPage.tsx +++ b/web/src/components/MetricsPage.tsx @@ -61,7 +61,7 @@ import { wrappable, } from '@patternfly/react-table'; import * as _ from 'lodash-es'; -import type { FC, Ref } from 'react'; +import type { FC, MouseEvent as ReactMouseEvent, Ref } from 'react'; import { useMemo, useCallback, useEffect, useState, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; @@ -1119,7 +1119,7 @@ const QueryBrowserWrapper: FC<{ }, [dispatch, queryParams, isFirstRender, setFirstRenderFalse]); /* eslint-disable react-hooks/exhaustive-deps */ - // Use React.useMemo() to prevent these two arrays being recreated on every render, which would + // Use useMemo() to prevent these two arrays being recreated on every render, which would // trigger unnecessary re-renders of QueryBrowser, which can be quite slow const queriesMemoKey = JSON.stringify(_.map(queries, 'query')); const queryStrings = useMemo(() => _.map(queries, 'query'), [queriesMemoKey]); @@ -1321,7 +1321,7 @@ const GraphUnitsDropDown: FC = () => { return intervalOptions.map((o) => ({ ...o, selected: o.value === selectedUnits })); }, [selectedUnits, t]); - const onSelect = (_ev: React.MouseEvent, selection: string) => { + const onSelect = (_ev: ReactMouseEvent, selection: string) => { setUnits(selection); }; @@ -1492,7 +1492,7 @@ const MetricsPage_: FC = () => { const MetricsPage = withFallback(MetricsPage_); -export const MpCmoMetricsPage: React.FC = () => { +export const MpCmoMetricsPage: FC = () => { return ( @@ -1500,7 +1500,7 @@ export const MpCmoMetricsPage: React.FC = () => { ); }; -export const MpCmoDevMetricsPage: React.FC = () => { +export const MpCmoDevMetricsPage: FC = () => { return ( = ({ options, onSelect, p } }; - const onTextInputChange = (_event: React.FormEvent, value: string) => { + const onTextInputChange = (_event: FormEvent, value: string) => { setInputValue(value); setFilterValue(value); @@ -125,7 +125,7 @@ export const TypeaheadSelect: FC = ({ options, onSelect, p closeMenu(); }; - const onInputKeyDown = (event: React.KeyboardEvent) => { + const onInputKeyDown = (event: KeyboardEvent) => { const focusedItem = focusedItemIndex !== null ? selectOptions[focusedItemIndex] : null; switch (event.key) { @@ -204,7 +204,7 @@ export const TypeaheadSelect: FC = ({ options, onSelect, p setActiveAndFocusedItem(indexToFocus); }; - const toggle = (toggleRef: React.Ref) => ( + const toggle = (toggleRef: Ref) => ( | undefined, value: string) => void; + onChange?: (event: FormEvent | undefined, value: string) => void; /** Controls visibility of the filter in the toolbar */ showToolbarItem?: ToolbarFilterProps['showToolbarItem']; /** Trims input value on change */ diff --git a/web/src/components/alerting/AlertsPage.tsx b/web/src/components/alerting/AlertsPage.tsx index 86b4e3f13..dfb212f12 100644 --- a/web/src/components/alerting/AlertsPage.tsx +++ b/web/src/components/alerting/AlertsPage.tsx @@ -98,11 +98,8 @@ const AlertsPage_: FC = () => { { label: t('Total'), key: rowFilter('alert-total') }, { label: t('State'), key: rowFilter(AlertFilterOptions.STATE) }, ]; - if (perspective === 'acm') { - keys.push({ label: t('Cluster'), key: rowFilter(AlertFilterOptions.CLUSTER) }); - } return keys; - }, [t, perspective]); + }, [t]); const columns = useTableColumns(columnKeys, sortBy, direction, onSort, [0]); diff --git a/web/src/components/console/utils/ref-width-hook.ts b/web/src/components/console/utils/ref-width-hook.ts index 114d2c7c9..b421e1d7f 100644 --- a/web/src/components/console/utils/ref-width-hook.ts +++ b/web/src/components/console/utils/ref-width-hook.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useCallback } from 'react'; +import { useEffect, useRef, useState, useCallback, Ref } from 'react'; export const useRefWidth = () => { const ref = useRef(null); @@ -28,5 +28,5 @@ export const useRefWidth = () => { } }, [ref, width]); - return [setRef, width] as [React.Ref, number]; + return [setRef, width] as [Ref, number]; }; diff --git a/web/src/components/console/utils/single-typeahead-dropdown.tsx b/web/src/components/console/utils/single-typeahead-dropdown.tsx index 50fde3fa4..8f09c5e8e 100644 --- a/web/src/components/console/utils/single-typeahead-dropdown.tsx +++ b/web/src/components/console/utils/single-typeahead-dropdown.tsx @@ -20,7 +20,17 @@ import * as _ from 'lodash-es'; import { useTranslation } from 'react-i18next'; import { TimesIcon } from '@patternfly/react-icons'; import { t_global_spacer_control_horizontal_default } from '@patternfly/react-tokens'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { + FC, + FormEvent, + KeyboardEvent, + MouseEvent as ReactMouseEvent, + Ref, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; export type SingleTypeaheadDropdownProps = { /** The items to display in the dropdown */ @@ -46,7 +56,7 @@ export type SingleTypeaheadDropdownProps = { /** Whether to enable creating new items */ enableCreateNew?: boolean; /** The component to use render the dropdown options */ - OptionComponent?: React.FC; + OptionComponent?: FC; /** Additional props to pass to MenuToggle */ menuToggleProps?: Partial; @@ -79,7 +89,7 @@ const getTextWidth = (text: string, font: string): number => { }; /** A PF Select with typeahead filtering and single selection */ -export const SingleTypeaheadDropdown: React.FC = ({ +export const SingleTypeaheadDropdown: FC = ({ items, onChange, onClear, @@ -202,7 +212,7 @@ export const SingleTypeaheadDropdown: React.FC = ( }; const onSelect = ( - _event: React.MouseEvent | undefined, + _event: ReactMouseEvent | undefined, value: string | number | undefined, ) => { if (enableCreateNew && value === CREATE_NEW) { @@ -224,7 +234,7 @@ export const SingleTypeaheadDropdown: React.FC = ( setInputValue(selectedValue?.children ?? selectedValue?.value ?? ''); }, [selectedValue]); - const onTextInputChange = (_event: React.FormEvent, value: string) => { + const onTextInputChange = (_event: FormEvent, value: string) => { setInputValue(value); setFilterValue(value); if (onInputChange) { @@ -279,7 +289,7 @@ export const SingleTypeaheadDropdown: React.FC = ( setActiveAndFocusedItem(indexToFocus); }; - const onInputKeyDown = (event: React.KeyboardEvent) => { + const onInputKeyDown = (event: KeyboardEvent) => { const focusedItem = focusedItemIndex !== null ? filteredSelectOptions[focusedItemIndex] : null; switch (event.key) { @@ -333,7 +343,7 @@ export const SingleTypeaheadDropdown: React.FC = ( ); }, [resizeToFit, selectedValue]); - const toggle = (toggleRef: React.Ref) => ( + const toggle = (toggleRef: Ref) => ( = ( value={inputValue} onClick={onInputClick} onChange={onTextInputChange} - onKeyDown={(ev: React.KeyboardEvent) => { + onKeyDown={(ev: KeyboardEvent) => { if (ev.key === 'Enter') { ev.preventDefault(); // prevent accidental form submission } diff --git a/web/src/components/dashboards/perses/PersesWrapper.tsx b/web/src/components/dashboards/perses/PersesWrapper.tsx index 9e063eafc..b51ac13c2 100644 --- a/web/src/components/dashboards/perses/PersesWrapper.tsx +++ b/web/src/components/dashboards/perses/PersesWrapper.tsx @@ -33,7 +33,7 @@ import { usePluginBuiltinVariableDefinitions, ValidationProvider, } from '@perses-dev/plugin-system'; -import React, { useMemo } from 'react'; +import { ReactNode, useMemo } from 'react'; import { usePatternFlyTheme } from '../../hooks/usePatternflyTheme'; import { OcpDatasourceApi } from './datasource-api'; import { PERSES_PROXY_BASE_PATH, useFetchPersesDashboard } from './perses-client'; @@ -74,7 +74,7 @@ const patternflyChartsMultiUnorderedPalette = Array.isArray(chartColorScale) : []; interface PersesWrapperProps { - children?: React.ReactNode; + children?: ReactNode; project: string; } @@ -412,7 +412,7 @@ export function PersesWrapper({ children, project }: PersesWrapperProps) { } interface InnerWrapperProps { - children?: React.ReactNode; + children?: ReactNode; project: string; } @@ -506,7 +506,7 @@ interface PersesPrometheusDatasourceWrapperProps { queries: Definition[]; dashboardResource?: DashboardResource; duration?: DurationString; - children?: React.ReactNode; + children?: ReactNode; } export function PersesPrometheusDatasourceWrapper({ diff --git a/web/src/components/dashboards/perses/ToastProvider.tsx b/web/src/components/dashboards/perses/ToastProvider.tsx index 024ebce7c..f9cf3f209 100644 --- a/web/src/components/dashboards/perses/ToastProvider.tsx +++ b/web/src/components/dashboards/perses/ToastProvider.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, ReactNode } from 'react'; +import { createContext, useContext, useState, ReactNode, FC } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, @@ -31,7 +31,7 @@ export const useToast = () => { return context; }; -export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => { +export const ToastProvider: FC<{ children: ReactNode }> = ({ children }) => { const [alerts, setAlerts] = useState([]); const addAlert = (title: string, variant: AlertProps['variant']) => { diff --git a/web/src/components/dashboards/perses/dashboard-action-modals.tsx b/web/src/components/dashboards/perses/dashboard-action-modals.tsx index 015b31368..66eb43d99 100644 --- a/web/src/components/dashboards/perses/dashboard-action-modals.tsx +++ b/web/src/components/dashboards/perses/dashboard-action-modals.tsx @@ -19,7 +19,7 @@ import { } from '@patternfly/react-core'; import { TypeaheadSelect, TypeaheadSelectOption } from '@patternfly/react-templates'; import { ExclamationCircleIcon } from '@patternfly/react-icons'; -import React, { useMemo } from 'react'; +import { CSSProperties, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useUpdateDashboardMutation, @@ -46,11 +46,11 @@ import { t_global_spacer_200, t_global_font_weight_200 } from '@patternfly/react import { useNavigate } from 'react-router'; import { usePerspective, getDashboardUrl } from '../../hooks/usePerspective'; -const formGroupStyle = { +export const formGroupStyle = { fontWeight: t_global_font_weight_200.value, -} as React.CSSProperties; +} as CSSProperties; -const LabelSpacer = () => { +export const LabelSpacer = () => { return
; }; @@ -217,8 +217,8 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal const dashboardName = form.watch('dashboardName'); const { schema: dynamicValidationSchema, isSchemaLoading } = useDashboardValidationSchema( - selectedProjectName, t, + selectedProjectName, ); const projectOptions = useMemo(() => { @@ -234,7 +234,7 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal const createDashboardMutation = useCreateDashboardMutation(); - React.useEffect(() => { + useEffect(() => { const isPerseProject = persesProjects?.some( (project) => project.metadata?.name === selectedProjectName, ); @@ -276,7 +276,7 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal persesProjects, ]); - React.useEffect(() => { + useEffect(() => { if (isOpen && dashboard && editableProjects?.length > 0 && defaultProject) { form.reset({ projectName: defaultProject, @@ -355,7 +355,7 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal const handleClose = () => { onClose(); - form.reset(); + form.reset({ dashboardName: '' }); }; const onProjectSelect = (_event: any, selection: string) => { diff --git a/web/src/components/dashboards/perses/dashboard-action-validations.ts b/web/src/components/dashboards/perses/dashboard-action-validations.ts index 66b61b39a..f626e2e44 100644 --- a/web/src/components/dashboards/perses/dashboard-action-validations.ts +++ b/web/src/components/dashboards/perses/dashboard-action-validations.ts @@ -3,17 +3,23 @@ import { useMemo } from 'react'; import { nameSchema } from '@perses-dev/core'; import { useDashboardList } from './dashboard-api'; import { generateMetadataName } from './dashboard-utils'; +import { TFunction } from 'i18next'; -export const createDashboardDisplayNameValidationSchema = (t: (key: string) => string) => +export const createDashboardDisplayNameValidationSchema = (t: TFunction) => z.string().min(1, t('Required')).max(75, t('Must be 75 or fewer characters long')); -export const createDashboardDialogValidationSchema = (t: (key: string) => string) => +export const createDashboardDialogValidationSchema = (t: TFunction) => z.object({ projectName: nameSchema, dashboardName: createDashboardDisplayNameValidationSchema(t), }); -export const renameDashboardDialogValidationSchema = (t: (key: string) => string) => +export const importDashboardDialogValidationSchema = () => + z.object({ + projectName: nameSchema, + }); + +export const renameDashboardDialogValidationSchema = (t: TFunction) => z.object({ dashboardName: createDashboardDisplayNameValidationSchema(t), }); @@ -24,6 +30,9 @@ export type CreateDashboardValidationType = z.infer< export type RenameDashboardValidationType = z.infer< ReturnType >; +export type ImportDashboardValidationType = z.infer< + ReturnType +>; export interface DashboardValidationSchema { schema?: z.ZodSchema; @@ -33,8 +42,8 @@ export interface DashboardValidationSchema { // Validate dashboard name and check if it doesn't already exist export function useDashboardValidationSchema( + t: TFunction, projectName?: string, - t?: (key: string, options?: any) => string, ): DashboardValidationSchema { const { data: dashboards, @@ -42,12 +51,13 @@ export function useDashboardValidationSchema( isError, } = useDashboardList({ project: projectName }); return useMemo((): DashboardValidationSchema => { - if (isDashboardsLoading) + if (isDashboardsLoading) { return { schema: undefined, isSchemaLoading: true, hasSchemaError: false, }; + } if (isError) { return { @@ -57,12 +67,13 @@ export function useDashboardValidationSchema( }; } - if (!dashboards?.length) + if (!dashboards?.length) { return { schema: createDashboardDialogValidationSchema(t), isSchemaLoading: false, hasSchemaError: false, }; + } const refinedSchema = createDashboardDialogValidationSchema(t).refine( (schema) => { @@ -75,13 +86,13 @@ export function useDashboardValidationSchema( }); }, (schema) => ({ - 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!`, + message: t( + `Dashboard name '{{dashboardName}}' already exists in '{{projectName}}' project!`, + { + dashboardName: schema.dashboardName, + projectName: schema.projectName, + }, + ), path: ['dashboardName'], }), ); diff --git a/web/src/components/dashboards/perses/dashboard-actions-menu.tsx b/web/src/components/dashboards/perses/dashboard-actions-menu.tsx index e4e83fdac..1ecb0fba1 100644 --- a/web/src/components/dashboards/perses/dashboard-actions-menu.tsx +++ b/web/src/components/dashboards/perses/dashboard-actions-menu.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { FC, Ref, useState } from 'react'; import { Dropdown, DropdownList, @@ -14,7 +14,7 @@ import { DashboardCreateDialog } from './dashboard-create-dialog'; import { DashboardImportDialog } from './dashboard-import-dialog'; import { persesDashboardDataTestIDs } from '../../data-test'; -export const DashboardActionsMenu: React.FunctionComponent = () => { +export const DashboardActionsMenu: FC = () => { const { t } = useTranslation(process.env.I18N_NAMESPACE); const { hasEditableProject, permissionsLoading } = useEditableProjects(); const [isDropdownOpen, setIsDropdownOpen] = useState(false); @@ -46,7 +46,7 @@ export const DashboardActionsMenu: React.FunctionComponent = () => { isOpen={isDropdownOpen} onSelect={onSelect} onOpenChange={(open: boolean) => setIsDropdownOpen(open)} - toggle={(toggleRef: React.Ref) => ( + toggle={(toggleRef: Ref) => ( void; } -export const DashboardCreateDialog: React.FunctionComponent = ({ - isOpen, - onClose, -}) => { +export const DashboardCreateDialog: FC = ({ isOpen, onClose }) => { const { t } = useTranslation(process.env.I18N_NAMESPACE); - const navigate = useNavigate(); - const { perspective } = usePerspective(); const { addAlert } = useToast(); - const { editableProjects, permissionsError } = useEditableProjects(); - const [selectedProject, setSelectedProject] = useState(null); - const [dashboardName, setDashboardName] = useState(''); - const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({}); - const createDashboardMutation = useCreateDashboardMutation(); - const createProjectMutation = useCreateProjectMutation(); - const { persesProjects } = usePerses(); - - const projectOptions = useMemo(() => { - if (!editableProjects) { - return []; - } - - return editableProjects?.map((project) => ({ - content: project, - value: project, - selected: project === selectedProject, - })); - }, [editableProjects, selectedProject]); - const { persesProjectDashboards: dashboards } = usePerses( - isOpen && selectedProject ? selectedProject : undefined, - ); + const { + editableProjects, + hasEditableProject, + permissionsLoading, + permissionsError, + persesProjects, + defaultProject, + projectOptions, + } = useDashboardProjects(); + + const { ensureProjectExists, isCreatingProject } = useProjectCreation(); + const { navigateToDashboard } = useDashboardNavigation(); + const createDashboardMutation = useCreateDashboardMutation(); - const handleSetDashboardName = (_event, dashboardName: string) => { - setDashboardName(dashboardName); - if (formErrors.dashboardName) { - setFormErrors((prev) => ({ ...prev, dashboardName: '' })); + const { schema: validationSchema } = useDashboardValidationSchema(t, defaultProject); + + const form = useForm({ + resolver: validationSchema + ? zodResolver(validationSchema) + : zodResolver(createDashboardDialogValidationSchema(t)), + mode: 'onBlur', + defaultValues: { + projectName: defaultProject, + dashboardName: '', + }, + }); + + useEffect(() => { + if (isOpen && editableProjects?.length > 0 && defaultProject) { + form.reset({ + projectName: defaultProject, + dashboardName: '', + }); } - }; + }, [isOpen, defaultProject, editableProjects?.length, form]); - const handleAdd = async () => { - setFormErrors({}); - - if (!selectedProject || !dashboardName.trim()) { - const errors: { [key: string]: string } = {}; - if (!selectedProject) errors.project = t('Project is required'); - if (!dashboardName.trim()) errors.dashboardName = t('Dashboard name is required'); - setFormErrors(errors); + const processForm: SubmitHandler = async (data) => { + try { + await ensureProjectExists(data.projectName, persesProjects || []); + } catch { return; } - try { - if ( - dashboards && - dashboards.some( - (d) => - d.metadata.project === selectedProject && - d.metadata.name.toLowerCase() === dashboardName.trim().toLowerCase(), - ) - ) { - setFormErrors({ - dashboardName: `Dashboard name "${dashboardName}" already exists in this project`, - }); - return; - } - - const projectExists = persesProjects?.some( - (project) => project.metadata.name === selectedProject, - ); - - if (!projectExists) { - try { - await createProjectMutation.mutateAsync(selectedProject as string); - addAlert( - t('Project "{{project}}" created successfully', { project: selectedProject }), - 'success', - ); - } catch (projectError) { - const errorMessage = - projectError?.message || - t('Failed to create project "{{project}}". Please try again.', { - project: selectedProject, - }); - addAlert(t('Error creating project: {{error}}', { error: errorMessage }), 'danger'); - setFormErrors({ general: errorMessage }); - return; - } - } - - const newDashboard: DashboardResource = createNewDashboard( - dashboardName.trim(), - selectedProject as string, - ); - - const createdDashboard = await createDashboardMutation.mutateAsync(newDashboard); - - addAlert(`Dashboard "${dashboardName}" created successfully`, 'success'); - - 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}`); - - handleClose(); - } catch (error) { - const errorMessage = error?.message || t('Failed to create dashboard. Please try again.'); - addAlert(`Error creating dashboard: ${errorMessage}`, 'danger'); - setFormErrors({ general: errorMessage }); - } + const newDashboard: DashboardResource = createNewDashboard( + data.dashboardName.trim(), + data.projectName, + ); + + createDashboardMutation.mutate(newDashboard, { + onSuccess: (createdDashboard: DashboardResource) => { + const msg = t(`Dashboard "${data.dashboardName}" created successfully`); + addAlert(msg, AlertVariant.success); + + handleClose(); + navigateToDashboard(createdDashboard, true); + }, + onError: (err) => { + const msg = t(`Could not create dashboard. ${err}`); + addAlert(msg, AlertVariant.danger); + }, + }); }; const handleClose = () => { - setDashboardName(''); - setFormErrors({}); - setSelectedProject(null); onClose(); - }; - - const onSelect = (_event: any, selection: string) => { - setSelectedProject(selection); + form.reset(); }; return ( @@ -161,101 +120,81 @@ export const DashboardCreateDialog: React.FunctionComponent - + - {permissionsError && ( - - )} - {formErrors.general && ( - - )} -
{ - e.preventDefault(); - handleAdd(); - }} + - - t('No project found for "{{filter}}"', { filter })} - onClearSelection={() => { - setSelectedProject(null); - }} - onSelect={onSelect} - isCreatable={false} - maxMenuHeight="200px" - /> - - - - {formErrors.dashboardName && ( - - - } - variant={HelperTextItemVariant.error} - > - {formErrors.dashboardName} - - - - )} - - + +
+ + + ( + + + + {fieldState.error && ( + + + + {fieldState.error.message} + + + + )} + + )} + /> + + + + + +
+
+
- diff --git a/web/src/components/dashboards/perses/dashboard-dialog-helpers.tsx b/web/src/components/dashboards/perses/dashboard-dialog-helpers.tsx new file mode 100644 index 000000000..0603c9de1 --- /dev/null +++ b/web/src/components/dashboards/perses/dashboard-dialog-helpers.tsx @@ -0,0 +1,213 @@ +import { + FormGroup, + FormHelperText, + HelperText, + HelperTextItem, + SelectOptionProps, + Spinner, +} from '@patternfly/react-core'; +import { TypeaheadSelect } from '@patternfly/react-templates'; +import { DashboardResource } from '@perses-dev/core'; +import { FC, ReactNode, useMemo } from 'react'; +import { Control, Controller } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; +import { getDashboardUrl, usePerspective } from '../../hooks/usePerspective'; +import { formGroupStyle, LabelSpacer } from './dashboard-action-modals'; +import { useCreateProjectMutation } from './dashboard-api'; +import { useEditableProjects } from './hooks/useEditableProjects'; +import { usePerses } from './hooks/usePerses'; +import { useToast } from './ToastProvider'; + +export const useDashboardProjects = () => { + const { + editableProjects, + allProjects, + hasEditableProject, + permissionsLoading, + permissionsError, + } = useEditableProjects(); + + const { persesProjects } = usePerses(); + + const defaultProject = useMemo(() => { + return allProjects?.[0] || ''; + }, [allProjects]); + + const projectOptions = useMemo(() => { + if (!editableProjects) { + return []; + } + return editableProjects.map((project) => ({ + name: project, + value: project, + content: project, + children: project, + })); + }, [editableProjects]); + + return { + editableProjects, + allProjects, + hasEditableProject, + permissionsLoading, + permissionsError, + persesProjects, + defaultProject, + projectOptions, + }; +}; + +export const useProjectCreation = () => { + const { t } = useTranslation(process.env.I18N_NAMESPACE); + const { addAlert } = useToast(); + const createProjectMutation = useCreateProjectMutation(); + + const ensureProjectExists = async (projectName: string, persesProjects: any[]) => { + const projectExists = persesProjects?.some((project) => project.metadata.name === projectName); + + if (!projectExists) { + try { + await createProjectMutation.mutateAsync(projectName); + addAlert( + t('Project "{{project}}" created successfully', { project: projectName }), + 'success', + ); + } catch (projectError) { + const errorMessage = + (() => { + if (projectError instanceof Error) return projectError.message; + if ( + typeof projectError === 'object' && + projectError !== null && + 'message' in projectError + ) { + return String((projectError as { message: unknown }).message); + } + return typeof projectError === 'string' ? projectError : undefined; + })() || + t('Failed to create project "{{project}}". Please try again.', { project: projectName }); + + addAlert(t('Error creating project: {{error}}', { error: errorMessage }), 'danger'); + throw projectError; // Re-throw to stop the calling operation + } + } + }; + + return { + ensureProjectExists, + isCreatingProject: createProjectMutation.isPending, + }; +}; + +export const useDashboardNavigation = () => { + const navigate = useNavigate(); + const { perspective } = usePerspective(); + + const navigateToDashboard = (dashboard: DashboardResource, editMode = true) => { + const dashboardUrl = getDashboardUrl(perspective); + const dashboardParam = `dashboard=${dashboard.metadata.name}`; + const projectParam = `project=${dashboard.metadata.project}`; + const editModeParam = editMode ? `edit=true` : ''; + const queryParams = [dashboardParam, projectParam, editModeParam].filter(Boolean).join('&'); + navigate(`${dashboardUrl}?${queryParams}`); + }; + + return { navigateToDashboard }; +}; + +interface PermissionStateProps { + permissionsLoading: boolean; + permissionsError: unknown; + children: ReactNode; +} + +export const PermissionStateWrapper: FC = ({ + permissionsLoading, + permissionsError, + children, +}) => { + const { t } = useTranslation(process.env.I18N_NAMESPACE); + + if (permissionsLoading) { + return ( +
+ {t('Loading...')} +
+ ); + } + + if (permissionsError) { + return ( +
+ {t('Failed to load project permissions. Please refresh the page and try again.')} +
+ ); + } + + return <>{children}; +}; + +interface ProjectSelectFormGroupProps { + control: Control; + projectOptions: SelectOptionProps[]; + defaultValue: string; + label?: string; + required?: boolean; + maxHeight?: string; +} + +export const ProjectSelectFormGroup: FC = ({ + control, + projectOptions, + defaultValue, + label, + required = true, + maxHeight = '200px', +}) => { + const { t } = useTranslation(process.env.I18N_NAMESPACE); + + return ( + { + const currentValue = field.value || defaultValue; + return ( + + + ({ + content: op.value, + value: op.value, + selected: op.value === currentValue, + }))} + placeholder={t('Select project')} + noOptionsFoundMessage={(filter) => + t('No namespace found for "{{filter}}"', { filter }) + } + onSelect={(_e, project) => { + field.onChange(project as string); + }} + isCreatable={false} + maxMenuHeight={maxHeight} + /> + {fieldState.error && ( + + + {fieldState.error.message} + + + )} + + ); + }} + /> + ); +}; diff --git a/web/src/components/dashboards/perses/dashboard-frame.tsx b/web/src/components/dashboards/perses/dashboard-frame.tsx index 588677071..0d73b8bb7 100644 --- a/web/src/components/dashboards/perses/dashboard-frame.tsx +++ b/web/src/components/dashboards/perses/dashboard-frame.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode } from 'react'; +import { FC, ReactNode } from 'react'; import { DashboardEmptyState } from './emptystates/DashboardEmptyState'; import { DashboardHeader } from './dashboard-header'; import { CombinedDashboardMetadata } from './hooks/useDashboardsData'; @@ -15,7 +15,7 @@ interface DashboardFrameProps { children: ReactNode; } -export const DashboardFrame: React.FC = ({ +export const DashboardFrame: FC = ({ activeProject, activeProjectDashboardsMetadata, changeBoard, diff --git a/web/src/components/dashboards/perses/dashboard-header.tsx b/web/src/components/dashboards/perses/dashboard-header.tsx index 602c78df4..c96efece8 100644 --- a/web/src/components/dashboards/perses/dashboard-header.tsx +++ b/web/src/components/dashboards/perses/dashboard-header.tsx @@ -1,5 +1,5 @@ import type { FC, PropsWithChildren } from 'react'; -import React, { memo } from 'react'; +import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { Divider, Stack, StackItem } from '@patternfly/react-core'; @@ -29,9 +29,7 @@ const shouldHideFavoriteButton = (): boolean => { return currentUrl.includes(DASHBOARD_VIEW_PATH); }; -const DashboardBreadCrumb: React.FunctionComponent<{ dashboardDisplayName?: string }> = ({ - dashboardDisplayName, -}) => { +const DashboardBreadCrumb: FC<{ dashboardDisplayName?: string }> = ({ dashboardDisplayName }) => { const { t } = useTranslation(process.env.I18N_NAMESPACE); const { perspective } = usePerspective(); @@ -74,9 +72,7 @@ const DashboardBreadCrumb: React.FunctionComponent<{ dashboardDisplayName?: stri ); }; -const DashboardPageHeader: React.FunctionComponent<{ dashboardDisplayName?: string }> = ({ - dashboardDisplayName, -}) => { +const DashboardPageHeader: FC<{ dashboardDisplayName?: string }> = ({ dashboardDisplayName }) => { const { t } = useTranslation(process.env.I18N_NAMESPACE); const hideFavBtn = shouldHideFavoriteButton(); @@ -97,7 +93,7 @@ const DashboardPageHeader: React.FunctionComponent<{ dashboardDisplayName?: stri ); }; -const DashboardListPageHeader: React.FunctionComponent = () => { +const DashboardListPageHeader: FC = () => { const { t } = useTranslation(process.env.I18N_NAMESPACE); const hideFavBtn = shouldHideFavoriteButton(); diff --git a/web/src/components/dashboards/perses/dashboard-import-dialog.tsx b/web/src/components/dashboards/perses/dashboard-import-dialog.tsx index 1f4621f83..b3df8e747 100644 --- a/web/src/components/dashboards/perses/dashboard-import-dialog.tsx +++ b/web/src/components/dashboards/perses/dashboard-import-dialog.tsx @@ -1,14 +1,13 @@ +import { zodResolver } from '@hookform/resolvers/zod'; import { CodeEditor } from '@patternfly/react-code-editor'; import { - Alert, + AlertVariant, Button, FileUpload, - Form, FormGroup, FormHelperText, HelperText, HelperTextItem, - HelperTextItemVariant, Modal, ModalBody, ModalFooter, @@ -18,22 +17,36 @@ import { StackItem, } from '@patternfly/react-core'; import { ExclamationCircleIcon } from '@patternfly/react-icons'; -import { TypeaheadSelect, TypeaheadSelectOption } from '@patternfly/react-templates'; import yaml from 'js-yaml'; -import { ChangeEvent, useMemo, useState } from 'react'; +import { ChangeEvent, FC, useEffect, useState } from 'react'; +import { FormProvider, SubmitHandler, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router'; -import { useEditableProjects } from './hooks/useEditableProjects'; +import { + PermissionStateWrapper, + ProjectSelectFormGroup, + useDashboardNavigation, + useDashboardProjects, + useProjectCreation, +} from './dashboard-dialog-helpers'; import { DashboardResource } from '@perses-dev/core'; import { usePatternFlyTheme } from '../../hooks/usePatternflyTheme'; -import { getDashboardUrl, usePerspective } from '../../hooks/usePerspective'; +import { + importDashboardDialogValidationSchema, + ImportDashboardValidationType, +} from './dashboard-action-validations'; import { useCreateDashboardMutation } from './dashboard-api'; import { useMigrateDashboard } from './migrate-api'; import { useToast } from './ToastProvider'; const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB -const ALLOWED_MIME_TYPES = ['application/json', 'text/yaml', 'application/x-yaml', 'text/x-yaml']; +const ALLOWED_MIME_TYPES = [ + 'application/json', + 'application/yaml', + 'text/yaml', + 'application/x-yaml', + 'text/x-yaml', +]; const getErrorMessage = (error: unknown): string | undefined => { if (error instanceof Error) return error.message; @@ -62,39 +75,47 @@ interface DashboardImportDialogProps { onClose: () => void; } -export const DashboardImportDialog: React.FunctionComponent = ({ - isOpen, - onClose, -}) => { +export const DashboardImportDialog: FC = ({ isOpen, onClose }) => { const { t } = useTranslation(process.env.I18N_NAMESPACE); - const navigate = useNavigate(); - const { perspective } = usePerspective(); const { addAlert } = useToast(); - const { editableProjects, permissionsError } = useEditableProjects(); + + const { + editableProjects, + permissionsLoading, + permissionsError, + persesProjects, + defaultProject, + projectOptions, + } = useDashboardProjects(); + + const { ensureProjectExists, isCreatingProject } = useProjectCreation(); + const { navigateToDashboard } = useDashboardNavigation(); const { theme } = usePatternFlyTheme(); - const [selectedProject, setSelectedProject] = useState(null); + const form = useForm({ + resolver: zodResolver(importDashboardDialogValidationSchema()), + mode: 'onBlur', + defaultValues: { + projectName: defaultProject, + }, + }); + const [dashboardInput, setDashboardInput] = useState(''); const [parsedDashboard, setParsedDashboard] = useState(); const [parseError, setParseError] = useState(''); const [filename, setFilename] = useState(''); - const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({}); const [isUploadingFile, setIsUploadingFile] = useState(false); const createDashboardMutation = useCreateDashboardMutation(); const migrateMutation = useMigrateDashboard(); - const projectOptions = useMemo(() => { - if (!editableProjects) { - return []; + useEffect(() => { + if (isOpen && editableProjects?.length > 0 && defaultProject) { + form.reset({ + projectName: defaultProject, + }); } - - return editableProjects.map((project) => ({ - content: project, - value: project, - selected: project === selectedProject, - })); - }, [editableProjects, selectedProject]); + }, [isOpen, defaultProject, editableProjects?.length, form]); const getDashboardType = (dashboard: Record): DashboardType => { if ('kind' in dashboard && dashboard.kind === 'Dashboard') { @@ -201,7 +222,8 @@ export const DashboardImportDialog: React.FunctionComponent { dashboard.metadata.project = projectName; @@ -211,38 +233,34 @@ export const DashboardImportDialog: React.FunctionComponent { + const processForm: SubmitHandler = async (data) => { if (isImporting) { return; } - setFormErrors({}); - - if (!selectedProject) { - setFormErrors({ project: t('Project is required') }); - return; - } - if (!parsedDashboard) { - setFormErrors({ dashboard: t('A valid dashboard is required') }); + addAlert(t('A valid dashboard is required'), AlertVariant.danger); return; } - // Capture current values before async operations to prevent race conditions - const currentProject = selectedProject; + const currentProject = data.projectName; const currentParsedDashboard = parsedDashboard; + try { + await ensureProjectExists(currentProject, persesProjects || []); + } catch { + return; + } + try { if (currentParsedDashboard.kind === 'grafana') { // Migrate Grafana dashboard first, then import @@ -260,15 +278,17 @@ export const DashboardImportDialog: React.FunctionComponent { const errorMessage = getErrorMessage(error) || t('Migration failed. Please try again.'); - setFormErrors({ general: errorMessage }); + addAlert( + t('Error migrating dashboard: {{error}}', { error: errorMessage }), + AlertVariant.danger, + ); }, }, ); @@ -279,7 +299,10 @@ export const DashboardImportDialog: React.FunctionComponent { - setSelectedProject(selection); - }; - - const canImport = parsedDashboard && selectedProject && !isImporting && !parseError; + const projectNameValue = form.watch('projectName'); + const canImport = parsedDashboard && projectNameValue && !isImporting && !parseError; return ( - + - {permissionsError && ( - - )} - {formErrors.general && ( - - )} - -
- - - - - - {t( - 'Upload a dashboard file or paste the dashboard definition directly in the editor below.', + + + + + + + + + {t( + 'Upload a dashboard file or paste the dashboard definition directly in the editor below.', + )} + + + + + + + + + + + {parseError && ( + + + } variant="error"> + {parseError} + + + )} - - - - - - + {parsedDashboard && ( + + + + {parsedDashboard.kind === 'grafana' + ? t( + 'Grafana dashboard detected. It will be automatically migrated to Perses format. Note: migration may be partial as not all Grafana features are supported.', + ) + : t('Perses dashboard detected.')} + + + + )} + + + + {parsedDashboard && ( - - - {(parseError || formErrors.dashboard) && ( - - - } - variant={HelperTextItemVariant.error} - > - {parseError || formErrors.dashboard} - - - - )} - {parsedDashboard && ( - - - - {parsedDashboard.kind === 'grafana' - ? t( - 'Grafana dashboard detected. It will be automatically migrated to Perses format. Note: migration may be partial as not all Grafana features are supported.', - ) - : t('Perses dashboard detected.')} - - - )} - - - - {parsedDashboard && ( - - - - t('No project found for "{{filter}}"', { filter }) - } - onClearSelection={() => { - setSelectedProject(null); - }} - onSelect={onProjectSelect} - isCreatable={false} - maxMenuHeight="200px" - /> - - - )} - -
+ + + +