diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/kind_field.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/kind_field.tsx index ac036fd8b30e2..c7a4378bd6fc4 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/kind_field.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/fields/kind_field.tsx @@ -6,14 +6,23 @@ */ import React from 'react'; -import { EuiCheckableCard, EuiText } from '@elastic/eui'; +import { EuiCheckableCard, EuiIconTip, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Controller, useFormContext } from 'react-hook-form'; import type { FormValues } from '../types'; const CARD_ID = 'ruleV2KindField'; -export const KindField = () => { +interface KindFieldProps { + disabled?: boolean; + compact?: boolean; +} + +const LABEL_TEXT = i18n.translate('xpack.alertingV2.ruleForm.kindField.checkboxLabel', { + defaultMessage: 'Track active and recovered state over time', +}); + +export const KindField = ({ disabled = false, compact = false }: KindFieldProps) => { const { control } = useFormContext(); return ( @@ -23,19 +32,43 @@ export const KindField = () => { render={({ field: { value, onChange } }) => { const isChecked = value === 'alert'; + if (compact) { + return ( + + {LABEL_TEXT}{' '} + + + } + checked={isChecked} + onChange={() => onChange(isChecked ? 'signal' : 'alert')} + disabled={disabled} + data-test-subj="kindField" + /> + ); + } + return ( - {i18n.translate('xpack.alertingV2.ruleForm.kindField.checkboxLabel', { - defaultMessage: 'Track active and recovered state over time', - })} - - } + label={{LABEL_TEXT}} checked={isChecked} onChange={() => onChange(isChecked ? 'signal' : 'alert')} + disabled={disabled} data-test-subj="kindField" > diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/gui_rule_form.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/gui_rule_form.tsx index b10ff20883d3a..dbed0c8122386 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/gui_rule_form.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/gui_rule_form.tsx @@ -21,6 +21,8 @@ export interface GuiRuleFormProps { onSubmit: (values: FormValues) => void; /** Whether to include the ES|QL query editor (default: true) */ includeQueryEditor?: boolean; + /** Whether the form is editing an existing rule (disables immutable fields like kind) */ + isEditing?: boolean; } /** @@ -35,7 +37,11 @@ export interface GuiRuleFormProps { * * Requires a FormProvider context with FormValues type to be present in the component tree. */ -export const GuiRuleForm = ({ onSubmit, includeQueryEditor = true }: GuiRuleFormProps) => { +export const GuiRuleForm = ({ + onSubmit, + includeQueryEditor = true, + isEditing = false, +}: GuiRuleFormProps) => { const { handleSubmit } = useFormContext(); return ( @@ -46,7 +52,7 @@ export const GuiRuleForm = ({ onSubmit, includeQueryEditor = true }: GuiRuleForm - + diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/index.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/index.tsx index 3677164434e77..e99bf558ea056 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/index.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/index.tsx @@ -62,3 +62,10 @@ export { mapRuleResponseToFormValues, } from './utils/rule_request_mappers'; export type { RuleRequestCommon } from './utils/rule_request_mappers'; + +// Field groups — for composing custom form layouts +export { RuleDetailsFieldGroup } from './field_groups/rule_details_field_group'; +export { ConditionFieldGroup } from './field_groups/condition_field_group'; +export { RuleExecutionFieldGroup } from './field_groups/rule_execution_field_group'; +export { AlertConditionsFieldGroup } from './field_groups/alert_conditions_field_group'; +export { KindField } from './fields/kind_field'; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/rule_form.tsx b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/rule_form.tsx index 559910981407f..696991b4f5c7f 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/rule_form.tsx +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/form/rule_form.tsx @@ -195,7 +195,11 @@ const RuleFormContent = ({ setYamlText={setYamlText} /> ) : ( - + )} {includeSubmission && ( diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/index.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/index.ts index ed898d38b490a..82c6c81f47056 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/index.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-rule-form/index.ts @@ -37,6 +37,15 @@ export { mapRuleResponseToFormValues, } from './form'; +// Field groups — for composing custom form layouts +export { + RuleDetailsFieldGroup, + ConditionFieldGroup, + RuleExecutionFieldGroup, + AlertConditionsFieldGroup, + KindField, +} from './form'; + // Types export type { FormValues, diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/rule/flyouts/index.ts b/x-pack/platform/plugins/shared/alerting_v2/public/components/rule/flyouts/index.ts index 209917104d472..973e5b8355af7 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/rule/flyouts/index.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/rule/flyouts/index.ts @@ -7,3 +7,6 @@ export { RuleSummaryFlyout } from './rule_summary_flyout'; export type { RuleSummaryFlyoutProps } from './rule_summary_flyout'; + +export { QuickEditRuleFlyout } from './quick_edit_rule_flyout'; +export type { QuickEditRuleFlyoutProps } from './quick_edit_rule_flyout'; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/rule/flyouts/quick_edit_rule_flyout.test.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/rule/flyouts/quick_edit_rule_flyout.test.tsx new file mode 100644 index 0000000000000..673ca68612492 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/rule/flyouts/quick_edit_rule_flyout.test.tsx @@ -0,0 +1,171 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n-react'; +import { QueryClient, QueryClientProvider } from '@kbn/react-query'; +import { QuickEditRuleFlyout } from './quick_edit_rule_flyout'; +import type { RuleApiResponse } from '../../../services/rules_api'; + +const mockMutate = jest.fn(); + +jest.mock('@kbn/core-di-browser', () => ({ + useService: () => ({}), + CoreStart: (key: string) => key, +})); + +jest.mock('@kbn/core-di', () => ({ + PluginStart: (key: string) => key, +})); + +jest.mock('../../../hooks/use_update_rule', () => ({ + useUpdateRule: () => ({ + mutate: mockMutate, + isLoading: false, + }), +})); + +jest.mock('@kbn/alerting-v2-rule-form', () => ({ + RuleFormProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + RuleDetailsFieldGroup: () =>
, + ConditionFieldGroup: () =>
, + RuleExecutionFieldGroup: () =>
, + AlertConditionsFieldGroup: () =>
, + KindField: ({ disabled, compact }: { disabled?: boolean; compact?: boolean }) => ( +
+ ), + mapRuleResponseToFormValues: (rule: unknown) => ({ + kind: 'alert', + metadata: { name: 'Test Rule', enabled: true, description: '', tags: [] }, + timeField: '@timestamp', + schedule: { every: '1m', lookback: '5m' }, + evaluation: { query: { base: 'FROM logs-*' } }, + recoveryPolicy: { type: 'no_breach' }, + stateTransitionAlertDelayMode: 'immediate', + stateTransitionRecoveryDelayMode: 'immediate', + }), + mapFormValuesToUpdateRequest: (values: unknown) => values, +})); + +const baseRule = { + id: 'rule-1', + kind: 'alert', + enabled: true, + metadata: { name: 'Test Rule' }, +} as RuleApiResponse; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + +const renderFlyout = ( + overrides: Partial> = {} +) => { + const props = { + rule: baseRule, + onClose: jest.fn(), + ...overrides, + }; + + const utils = render( + + + + + + ); + + return { ...utils, props }; +}; + +describe('QuickEditRuleFlyout', () => { + beforeEach(() => { + mockMutate.mockClear(); + }); + + it('renders the flyout with the title and info tooltip', () => { + renderFlyout(); + + expect(screen.getByTestId('quickEditRuleFlyout')).toBeInTheDocument(); + expect(screen.getByText('Quick Edit Alert Rule')).toBeInTheDocument(); + }); + + it('renders all field groups', () => { + renderFlyout(); + + expect(screen.getByTestId('mockRuleDetailsFieldGroup')).toBeInTheDocument(); + expect(screen.getByTestId('mockConditionFieldGroup')).toBeInTheDocument(); + expect(screen.getByTestId('mockRuleExecutionFieldGroup')).toBeInTheDocument(); + expect(screen.getByTestId('mockAlertConditionsFieldGroup')).toBeInTheDocument(); + }); + + it('renders KindField as disabled and compact', () => { + renderFlyout(); + + const kindField = screen.getByTestId('mockKindField'); + expect(kindField).toHaveAttribute('data-disabled', 'true'); + expect(kindField).toHaveAttribute('data-compact', 'true'); + }); + + it('calls onClose when the close button is clicked', () => { + const { props } = renderFlyout(); + + fireEvent.click(screen.getByTestId('quickEditRuleFlyoutCloseButton')); + + expect(props.onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when the cancel button is clicked', () => { + const { props } = renderFlyout(); + + fireEvent.click(screen.getByTestId('quickEditRuleFlyoutCancelButton')); + + expect(props.onClose).toHaveBeenCalledTimes(1); + }); + + it('calls updateRule.mutate with the rule id when the form is submitted', async () => { + renderFlyout(); + + fireEvent.click(screen.getByTestId('quickEditRuleFlyoutSubmitButton')); + + await waitFor(() => { + expect(mockMutate).toHaveBeenCalledTimes(1); + expect(mockMutate).toHaveBeenCalledWith( + expect.objectContaining({ id: 'rule-1' }), + expect.objectContaining({ onSuccess: expect.any(Function) }) + ); + }); + }); + + it('calls onClose on successful mutation', async () => { + const { props } = renderFlyout(); + + fireEvent.click(screen.getByTestId('quickEditRuleFlyoutSubmitButton')); + + await waitFor(() => { + expect(mockMutate).toHaveBeenCalledTimes(1); + }); + + const { onSuccess } = mockMutate.mock.calls[0][1]; + onSuccess(); + + expect(props.onClose).toHaveBeenCalledTimes(1); + }); + + it('does not call onClose when the mutation fails', async () => { + const { props } = renderFlyout(); + + fireEvent.click(screen.getByTestId('quickEditRuleFlyoutSubmitButton')); + + await waitFor(() => { + expect(mockMutate).toHaveBeenCalledTimes(1); + }); + + expect(props.onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/rule/flyouts/quick_edit_rule_flyout.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/rule/flyouts/quick_edit_rule_flyout.tsx new file mode 100644 index 0000000000000..7e7b3651aa367 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/rule/flyouts/quick_edit_rule_flyout.tsx @@ -0,0 +1,240 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiForm, + EuiHorizontalRule, + EuiIconTip, + EuiPanel, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import { useService, CoreStart } from '@kbn/core-di-browser'; +import { PluginStart } from '@kbn/core-di'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import type { LensPublicStart } from '@kbn/lens-plugin/public'; +import { FormProvider, useForm } from 'react-hook-form'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + RuleFormProvider, + RuleDetailsFieldGroup, + ConditionFieldGroup, + RuleExecutionFieldGroup, + AlertConditionsFieldGroup, + KindField, + mapRuleResponseToFormValues, + mapFormValuesToUpdateRequest, +} from '@kbn/alerting-v2-rule-form'; +import type { FormValues } from '@kbn/alerting-v2-rule-form'; +import type { RuleApiResponse } from '../../../services/rules_api'; +import { useUpdateRule } from '../../../hooks/use_update_rule'; + +const FLYOUT_TITLE_ID = 'quickEditRuleFlyoutTitle'; + +export interface QuickEditRuleFlyoutProps { + rule: RuleApiResponse; + onClose: () => void; +} + +export const QuickEditRuleFlyout = ({ rule, onClose }: QuickEditRuleFlyoutProps) => { + const http = useService(CoreStart('http')); + const notifications = useService(CoreStart('notifications')); + const application = useService(CoreStart('application')); + const data = useService(PluginStart('data')) as DataPublicPluginStart; + const dataViews = useService(PluginStart('dataViews')) as DataViewsPublicPluginStart; + const lens = useService(PluginStart('lens')) as LensPublicStart; + + const ruleFormServices = useMemo( + () => ({ http, data, dataViews, notifications, application, lens }), + [http, data, dataViews, notifications, application, lens] + ); + + const defaultValues = useMemo(() => { + const mapped = mapRuleResponseToFormValues(rule); + return { + kind: mapped.kind ?? 'alert', + metadata: { + name: mapped.metadata?.name ?? '', + enabled: mapped.metadata?.enabled ?? true, + description: mapped.metadata?.description ?? '', + owner: mapped.metadata?.owner, + tags: mapped.metadata?.tags, + }, + timeField: mapped.timeField ?? '@timestamp', + schedule: { + every: mapped.schedule?.every ?? '1m', + lookback: mapped.schedule?.lookback ?? '5m', + }, + evaluation: { + query: { + base: mapped.evaluation?.query?.base ?? '', + }, + }, + grouping: mapped.grouping, + recoveryPolicy: mapped.recoveryPolicy ?? { type: 'no_breach' }, + stateTransition: mapped.stateTransition, + stateTransitionAlertDelayMode: mapped.stateTransitionAlertDelayMode ?? 'immediate', + stateTransitionRecoveryDelayMode: mapped.stateTransitionRecoveryDelayMode ?? 'immediate', + artifacts: mapped.artifacts, + }; + }, [rule]); + + const methods = useForm({ + mode: 'onBlur', + defaultValues, + }); + + const updateRuleMutation = useUpdateRule(); + + const handleSubmit = (values: FormValues) => { + const payload = mapFormValuesToUpdateRequest(values); + updateRuleMutation.mutate({ id: rule.id, payload }, { onSuccess: () => onClose() }); + }; + + return ( + + + + + + + + + + +

+ +

+
+
+ + + +
+
+ + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ ); +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/rule/flyouts/rule_summary_flyout.test.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/rule/flyouts/rule_summary_flyout.test.tsx index 9cde1b4bf1761..db6399671611e 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/rule/flyouts/rule_summary_flyout.test.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/rule/flyouts/rule_summary_flyout.test.tsx @@ -71,6 +71,7 @@ const renderFlyout = (overrides: Partial { ); }); + it('calls onQuickEdit with the rule when the pencil icon is clicked', () => { + const { props } = renderFlyout(); + + fireEvent.click(screen.getByTestId('ruleSummaryFlyoutQuickEditButton')); + + expect(props.onQuickEdit).toHaveBeenCalledWith(baseRule); + }); + it('forwards action callbacks to the RuleActionsMenu with the rule', () => { const { props } = renderFlyout(); diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/rule/flyouts/rule_summary_flyout.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/rule/flyouts/rule_summary_flyout.tsx index a84b937df9e99..b382a33626e47 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/rule/flyouts/rule_summary_flyout.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/rule/flyouts/rule_summary_flyout.tsx @@ -40,6 +40,7 @@ export interface RuleSummaryFlyoutProps { rule: RuleApiResponse; onClose: () => void; onEdit: (rule: RuleApiResponse) => void; + onQuickEdit: (rule: RuleApiResponse) => void; onClone: (rule: RuleApiResponse) => void; onDelete: (rule: RuleApiResponse) => void; onToggleEnabled: (rule: RuleApiResponse) => void; @@ -49,6 +50,7 @@ export const RuleSummaryFlyout = ({ rule, onClose, onEdit, + onQuickEdit, onClone, onDelete, onToggleEnabled, @@ -82,6 +84,17 @@ export const RuleSummaryFlyout = ({ responsive={false} alignItems="center" > + + onQuickEdit(rule)} + aria-label={i18n.translate('xpack.alertingV2.ruleSummaryFlyout.quickEdit', { + defaultMessage: 'Quick edit rule', + })} + data-test-subj="ruleSummaryFlyoutQuickEditButton" + /> + { + const rulesApi = useService(RulesApi); + const { toasts } = useService(CoreStart('notifications')); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, payload }: { id: string; payload: UpdateRuleData }) => + rulesApi.updateRule(id, payload), + onSuccess: (_data, variables) => { + toasts.addSuccess( + i18n.translate('xpack.alertingV2.hooks.useUpdateRule.successMessage', { + defaultMessage: 'Rule updated successfully', + }) + ); + queryClient.invalidateQueries(ruleKeys.lists()); + queryClient.invalidateQueries(ruleKeys.tags()); + queryClient.invalidateQueries(ruleKeys.detail(variables.id)); + }, + onError: () => { + toasts.addDanger( + i18n.translate('xpack.alertingV2.hooks.useUpdateRule.errorMessage', { + defaultMessage: 'Failed to update rule', + }) + ); + }, + }); +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_table.test.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_table.test.tsx index 8661e143fadba..6b266ad8c8c85 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_table.test.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_table.test.tsx @@ -81,6 +81,7 @@ const defaultProps: RulesListTableProps = { onBulkDelete: jest.fn(), onNavigateToDetails: jest.fn(), onExpand: jest.fn(), + onQuickEdit: jest.fn(), onEdit: jest.fn(), onClone: jest.fn(), onDelete: jest.fn(), diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_table.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_table.tsx index 096e81754947f..1d10e3bea00d4 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_table.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_table.tsx @@ -93,6 +93,7 @@ export interface RulesListTableProps { /** Row action callbacks */ onNavigateToDetails: (rule: RuleApiResponse) => void; onExpand: (rule: RuleApiResponse) => void; + onQuickEdit: (rule: RuleApiResponse) => void; onEdit: (rule: RuleApiResponse) => void; onClone: (rule: RuleApiResponse) => void; onDelete: (rule: RuleApiResponse) => void; @@ -125,6 +126,7 @@ export const RulesListTable: React.FC = ({ onBulkDelete, onNavigateToDetails, onExpand, + onQuickEdit, onEdit, onClone, onDelete, @@ -352,16 +354,36 @@ export const RulesListTable: React.FC = ({ defaultMessage="Actions" /> ), - width: '6%', + width: '8%', align: 'right', render: (rule: RuleApiResponse) => ( - + + + onQuickEdit(rule)} + aria-label={i18n.translate('xpack.alertingV2.rulesList.action.quickEdit', { + defaultMessage: 'Quick edit rule', + })} + data-test-subj={`quickEditRule-${rule.id}`} + /> + + + + + ), }, ], @@ -372,6 +394,7 @@ export const RulesListTable: React.FC = ({ onSelectRow, onNavigateToDetails, onExpand, + onQuickEdit, onEdit, onClone, onDelete, diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_table_container.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_table_container.tsx index 8cdcfaf484df0..56b78688213ea 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_table_container.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_table_container.tsx @@ -15,7 +15,7 @@ import { useBulkDeleteRules } from '../../hooks/use_bulk_delete_rules'; import { useBulkEnableRules, useBulkDisableRules } from '../../hooks/use_bulk_enable_disable_rules'; import { useToggleRuleEnabled } from '../../hooks/use_toggle_rule_enabled'; import { DeleteConfirmationModal } from '../../components/rule/modals/delete_confirmation_modal'; -import { RuleSummaryFlyout } from '../../components/rule/flyouts'; +import { RuleSummaryFlyout, QuickEditRuleFlyout } from '../../components/rule/flyouts'; import { paths } from '../../constants'; import { RulesListTable, type RulesListTableSortField } from './rules_list_table'; @@ -52,9 +52,13 @@ export const RulesListTableContainer: React.FC = ( const [ruleToDelete, setRuleToDelete] = useState(null); const [expandedRuleId, setExpandedRuleId] = useState(null); + const [quickEditRuleId, setQuickEditRuleId] = useState(null); const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false); const expandedRule = expandedRuleId ? items.find((r) => r.id === expandedRuleId) ?? null : null; + const quickEditRule = quickEditRuleId + ? items.find((r) => r.id === quickEditRuleId) ?? null + : null; const deleteRuleMutation = useDeleteRule(); const bulkDeleteMutation = useBulkDeleteRules(); @@ -139,7 +143,14 @@ export const RulesListTableContainer: React.FC = ( onBulkDisable={handleBulkDisable} onBulkDelete={handleBulkDelete} onNavigateToDetails={(r) => navigateToUrl(basePath.prepend(paths.ruleDetails(r.id)))} - onExpand={(r) => setExpandedRuleId(r.id)} + onExpand={(r) => { + setQuickEditRuleId(null); + setExpandedRuleId(r.id); + }} + onQuickEdit={(r) => { + setExpandedRuleId(null); + setQuickEditRuleId(r.id); + }} onEdit={(r) => navigateToUrl(basePath.prepend(paths.ruleEdit(r.id)))} onClone={(r) => navigateToUrl( @@ -154,6 +165,10 @@ export const RulesListTableContainer: React.FC = ( setExpandedRuleId(null)} + onQuickEdit={(r) => { + setExpandedRuleId(null); + setQuickEditRuleId(r.id); + }} onEdit={(r) => navigateToUrl(basePath.prepend(paths.ruleEdit(r.id)))} onClone={(r) => navigateToUrl( @@ -164,6 +179,13 @@ export const RulesListTableContainer: React.FC = ( onToggleEnabled={(r) => toggleEnabledMutation.mutate({ id: r.id, enabled: !r.enabled })} /> ) : null} + {quickEditRule ? ( + setQuickEditRuleId(null)} + /> + ) : null} {ruleToDelete ? ( { + let ruleId: string; + const ruleName = 'scout-quick-edit-rule'; + const updatedName = 'scout-quick-edit-updated'; + + test.beforeAll(async ({ apiServices }) => { + await apiServices.alertingV2.rules.cleanUp(); + const rule = await apiServices.alertingV2.rules.create( + buildCreateRuleData({ metadata: { name: ruleName } }) + ); + ruleId = rule.id; + }); + + test.beforeEach(async ({ browserAuth, pageObjects }) => { + await browserAuth.loginAsAlertingV2Editor(); + await pageObjects.rulesList.goto(); + await expect(pageObjects.rulesList.rulesListTable).toBeVisible({ timeout: 60_000 }); + }); + + test.afterAll(async ({ apiServices }) => { + await apiServices.alertingV2.rules.cleanUp(); + }); + + test('opens the quick edit flyout from the table pencil icon', async ({ page, pageObjects }) => { + await test.step('click the pencil icon in the actions column', async () => { + await pageObjects.rulesList.quickEditButton(ruleId).click(); + }); + + await test.step('quick edit flyout is visible with the rule name populated', async () => { + await expect(pageObjects.rulesList.quickEditFlyout).toBeVisible(); + await expect(pageObjects.rulesList.quickEditNameInput).toHaveValue(ruleName); + }); + + await test.step('quick edit flyout passes a11y checks', async () => { + const { violations } = await page.checkA11y({ + include: ['[data-test-subj="quickEditRuleFlyout"]'], + }); + expect(violations).toHaveLength(0); + }); + + await test.step('close the flyout', async () => { + await pageObjects.rulesList.quickEditCloseButton.click(); + await expect(pageObjects.rulesList.quickEditFlyout).toBeHidden(); + }); + }); + + test('opens the quick edit flyout from the rule summary flyout', async ({ pageObjects }) => { + await test.step('open the rule summary flyout by clicking the expand button', async () => { + await pageObjects.rulesList.expandRuleButton(ruleId).click(); + await expect(pageObjects.rulesList.ruleSummaryFlyout).toBeVisible(); + }); + + await test.step('click the pencil icon in the summary flyout header', async () => { + await pageObjects.rulesList.ruleSummaryQuickEditButton.click(); + }); + + await test.step('summary flyout closes and quick edit flyout opens', async () => { + await expect(pageObjects.rulesList.ruleSummaryFlyout).toBeHidden(); + await expect(pageObjects.rulesList.quickEditFlyout).toBeVisible(); + await expect(pageObjects.rulesList.quickEditNameInput).toHaveValue(ruleName); + }); + + await test.step('close the flyout', async () => { + await pageObjects.rulesList.quickEditCancelButton.click(); + await expect(pageObjects.rulesList.quickEditFlyout).toBeHidden(); + }); + }); + + test('edits a rule name and persists the change', async ({ pageObjects, apiServices }) => { + await test.step('open quick edit and change the rule name', async () => { + await pageObjects.rulesList.quickEditButton(ruleId).click(); + await expect(pageObjects.rulesList.quickEditFlyout).toBeVisible(); + await pageObjects.rulesList.quickEditNameInput.fill(updatedName); + }); + + await test.step('submit the form', async () => { + await pageObjects.rulesList.quickEditSubmitButton.click(); + await expect(pageObjects.rulesList.quickEditFlyout).toBeHidden({ timeout: 30_000 }); + }); + + await test.step('verify the rule name was updated via API', async () => { + await expect + .poll( + async () => { + const rule = await apiServices.alertingV2.rules.get(ruleId); + return rule.metadata?.name; + }, + { timeout: 30_000 } + ) + .toBe(updatedName); + }); + }); + + test('cancel discards changes', async ({ pageObjects, apiServices }) => { + const currentName = (await apiServices.alertingV2.rules.get(ruleId)).metadata?.name; + + await test.step('open quick edit, change the name, then cancel', async () => { + await pageObjects.rulesList.quickEditButton(ruleId).click(); + await expect(pageObjects.rulesList.quickEditFlyout).toBeVisible(); + await pageObjects.rulesList.quickEditNameInput.fill('should-not-persist'); + await pageObjects.rulesList.quickEditCancelButton.click(); + await expect(pageObjects.rulesList.quickEditFlyout).toBeHidden(); + }); + + await test.step('verify the rule name was not changed', async () => { + const rule = await apiServices.alertingV2.rules.get(ruleId); + expect(rule.metadata?.name).toBe(currentName); + }); + }); + + test('opening the summary flyout closes the quick edit flyout', async ({ pageObjects }) => { + await test.step('open the quick edit flyout', async () => { + await pageObjects.rulesList.quickEditButton(ruleId).click(); + await expect(pageObjects.rulesList.quickEditFlyout).toBeVisible(); + }); + + await test.step('click the expand button to open the summary flyout', async () => { + await pageObjects.rulesList.expandRuleButton(ruleId).click(); + }); + + await test.step('quick edit closes and summary flyout opens', async () => { + await expect(pageObjects.rulesList.quickEditFlyout).toBeHidden(); + await expect(pageObjects.rulesList.ruleSummaryFlyout).toBeVisible(); + }); + }); +});