From 274e369cd3a2fb5c001ca4fdb79b5481b6d669f9 Mon Sep 17 00:00:00 2001 From: jillguyonnet Date: Wed, 13 May 2026 16:51:59 +0200 Subject: [PATCH 1/9] [Fleet] Add UI for integration namespace customization --- .../shared/fleet/common/services/index.ts | 2 + .../services/is_valid_namespace.test.ts | 8 + .../common/services/is_valid_namespace.ts | 13 +- .../services/namespace_prefixes.test.ts | 29 +++ .../common/services/namespace_prefixes.ts | 20 ++ .../steps/step_define_package_policy.test.tsx | 212 ++++++++++++++++ .../steps/step_define_package_policy.tsx | 185 ++++++++++++++ .../hooks/use_get_agent_policy_or_default.tsx | 5 +- .../apply_namespace_customization.test.ts | 116 +++++++++ .../services/apply_namespace_customization.ts | 70 ++++++ .../services/index.ts | 1 + .../single_page_layout/index.tsx | 51 +++- .../edit_package_policy_page/index.tsx | 53 ++++ .../epm/screens/detail/components/index.tsx | 1 + .../namespace_customization_section.tsx | 226 ++++++++++++++++++ .../screens/detail/settings/settings.test.tsx | 53 ++++ .../epm/screens/detail/settings/settings.tsx | 78 +++++- .../services/spaces/policy_namespaces.ts | 16 +- 18 files changed, 1116 insertions(+), 23 deletions(-) create mode 100644 x-pack/platform/plugins/shared/fleet/common/services/namespace_prefixes.test.ts create mode 100644 x-pack/platform/plugins/shared/fleet/common/services/namespace_prefixes.ts create mode 100644 x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/apply_namespace_customization.test.ts create mode 100644 x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/apply_namespace_customization.ts create mode 100644 x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.tsx diff --git a/x-pack/platform/plugins/shared/fleet/common/services/index.ts b/x-pack/platform/plugins/shared/fleet/common/services/index.ts index 47d6f342e73bd..c756ccc22c240 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/index.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/index.ts @@ -138,6 +138,8 @@ export * from './cloud_connectors'; export { validateSslCertPath } from './ssl_validators'; +export { isNamespaceAllowedByPrefixes } from './namespace_prefixes'; + export type { YamlModule } from './yaml_utils'; export { createYamlKeysSorter, toYaml } from './yaml_utils'; export { diff --git a/x-pack/platform/plugins/shared/fleet/common/services/is_valid_namespace.test.ts b/x-pack/platform/plugins/shared/fleet/common/services/is_valid_namespace.test.ts index f35fb4af2f142..0350e4bab5415 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/is_valid_namespace.test.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/is_valid_namespace.test.ts @@ -41,5 +41,13 @@ describe('Fleet - isValidNamespace', () => { ).valid ).toBe(false); expect(isValidNamespace('default', false, ['test']).valid).toBe(false); + expect(isValidNamespace('default', false, ['prod', 'qa']).valid).toBe(false); + }); + + it('accepts a namespace matching any of multiple allowed prefixes', () => { + expect(isValidNamespace('prod', false, ['prod', 'qa']).valid).toBe(true); + expect(isValidNamespace('qa', false, ['prod', 'qa']).valid).toBe(true); + expect(isValidNamespace('prodenv', false, ['prod', 'qa']).valid).toBe(true); + expect(isValidNamespace('qaenv', false, ['prod', 'qa']).valid).toBe(true); }); }); diff --git a/x-pack/platform/plugins/shared/fleet/common/services/is_valid_namespace.ts b/x-pack/platform/plugins/shared/fleet/common/services/is_valid_namespace.ts index bb7d6bb2f49c5..914d545b165ed 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/is_valid_namespace.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/is_valid_namespace.ts @@ -24,15 +24,18 @@ export function isValidNamespace( return { valid, error }; } - for (const prefix of allowedNamespacePrefixes || []) { - if (!namespace.trim().startsWith(prefix)) { - return allowedNamespacePrefixes?.length === 1 + if (allowedNamespacePrefixes && allowedNamespacePrefixes.length > 0) { + const matchesAnyPrefix = allowedNamespacePrefixes.some((prefix) => + namespace.trim().startsWith(prefix) + ); + if (!matchesAnyPrefix) { + return allowedNamespacePrefixes.length === 1 ? { valid: false, error: i18n.translate('xpack.fleet.namespaceValidation.notAllowedPrefixError', { defaultMessage: 'Namespace should start with {allowedNamespacePrefixes}', values: { - allowedNamespacePrefixes: allowedNamespacePrefixes?.[0], + allowedNamespacePrefixes: allowedNamespacePrefixes[0], }, }), } @@ -42,7 +45,7 @@ export function isValidNamespace( defaultMessage: 'Namespace should start with one of these prefixes {allowedNamespacePrefixes}', values: { - allowedNamespacePrefixes: allowedNamespacePrefixes?.join(', ') ?? '', + allowedNamespacePrefixes: allowedNamespacePrefixes.join(', '), }, }), }; diff --git a/x-pack/platform/plugins/shared/fleet/common/services/namespace_prefixes.test.ts b/x-pack/platform/plugins/shared/fleet/common/services/namespace_prefixes.test.ts new file mode 100644 index 0000000000000..47eeff5919856 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/common/services/namespace_prefixes.test.ts @@ -0,0 +1,29 @@ +/* + * 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 { isNamespaceAllowedByPrefixes } from './namespace_prefixes'; + +describe('isNamespaceAllowedByPrefixes', () => { + it('returns true when prefixes are null (no restriction)', () => { + expect(isNamespaceAllowedByPrefixes('production', null)).toBe(true); + expect(isNamespaceAllowedByPrefixes('', null)).toBe(true); + }); + + it('returns true when namespace starts with one of the allowed prefixes', () => { + expect(isNamespaceAllowedByPrefixes('production_west', ['production', 'staging'])).toBe(true); + expect(isNamespaceAllowedByPrefixes('staging', ['production', 'staging'])).toBe(true); + }); + + it('returns false when namespace does not match any allowed prefix', () => { + expect(isNamespaceAllowedByPrefixes('dev', ['production', 'staging'])).toBe(false); + }); + + it('returns false when prefix list is empty', () => { + // Empty array means restriction is in effect but nothing is allowed. + expect(isNamespaceAllowedByPrefixes('anything', [])).toBe(false); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/common/services/namespace_prefixes.ts b/x-pack/platform/plugins/shared/fleet/common/services/namespace_prefixes.ts new file mode 100644 index 0000000000000..4509ec87866f0 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/common/services/namespace_prefixes.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +/** + * Returns true if the namespace is permitted by the given prefix list. + * `null` means no restriction (anything is permitted). + */ +export function isNamespaceAllowedByPrefixes( + namespace: string, + prefixes: string[] | null +): boolean { + if (prefixes === null) { + return true; + } + return prefixes.some((prefix) => namespace.startsWith(prefix)); +} diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.test.tsx index 6af898e51c892..3fe46ceca6961 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.test.tsx @@ -16,6 +16,8 @@ import type { TestRenderer } from '../../../../../../../mock'; import { createFleetTestRendererMock } from '../../../../../../../mock'; import type { AgentPolicy, NewPackagePolicy, PackageInfo } from '../../../../../types'; +import { useGetPackagePoliciesQuery } from '../../../../../hooks'; + import { StepDefinePackagePolicy } from './step_define_package_policy'; jest.mock('./components/hooks', () => ({ @@ -27,6 +29,11 @@ jest.mock('./components/hooks', () => ({ }), })); +jest.mock('../../../../../hooks', () => ({ + ...jest.requireActual('../../../../../hooks'), + useGetPackagePoliciesQuery: jest.fn().mockReturnValue({ data: { items: [] } }), +})); + describe('StepDefinePackagePolicy', () => { const packageInfo: PackageInfo = { name: 'apache', @@ -421,4 +428,209 @@ describe('StepDefinePackagePolicy', () => { } }); }); + + describe('namespace customization toggle', () => { + const renderWithToggle = (overrides: { + packagePolicyOverride?: Partial; + namespaceCustomizationEnabled?: boolean; + installedNamespaceCustomizationEnabledFor?: string[]; + allowedNamespacePrefixes?: string[]; + onNamespaceCustomizationEnabledChange?: (enabled: boolean) => void; + }) => { + const policy = { ...packagePolicy, ...(overrides.packagePolicyOverride ?? {}) }; + return testRenderer.render( + + ); + }; + + it('renders the toggle in advanced options when all props are provided', async () => { + renderResult = renderWithToggle({}); + await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); + await waitFor(() => { + expect( + renderResult.getByTestId('packagePolicyNamespaceCustomizationToggle') + ).toBeInTheDocument(); + }); + }); + + it('does not render the toggle when the wiring props are missing', async () => { + renderResult = testRenderer.render( + + ); + await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); + await waitFor(() => { + expect( + renderResult.queryByTestId('packagePolicyNamespaceCustomizationToggle') + ).not.toBeInTheDocument(); + }); + }); + + it('is disabled when the namespace fails prefix validation', async () => { + renderResult = renderWithToggle({ + packagePolicyOverride: { namespace: 'staging' }, + allowedNamespacePrefixes: ['production'], + }); + await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); + await waitFor(() => { + const toggle = renderResult.getByTestId('packagePolicyNamespaceCustomizationToggle'); + expect(toggle).toBeDisabled(); + }); + }); + + it('is enabled when the namespace matches an allowed prefix', async () => { + renderResult = renderWithToggle({ + packagePolicyOverride: { namespace: 'production_west' }, + allowedNamespacePrefixes: ['production'], + }); + await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); + await waitFor(() => { + const toggle = renderResult.getByTestId('packagePolicyNamespaceCustomizationToggle'); + expect(toggle).not.toBeDisabled(); + }); + }); + + it('calls onNamespaceCustomizationEnabledChange when toggled', async () => { + const onChange = jest.fn(); + renderResult = renderWithToggle({ + packagePolicyOverride: { namespace: 'staging' }, + onNamespaceCustomizationEnabledChange: onChange, + }); + await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); + const toggle = await renderResult.findByTestId('packagePolicyNamespaceCustomizationToggle'); + await userEvent.click(toggle); + expect(onChange).toHaveBeenCalledWith(true); + }); + + describe('impact warnings', () => { + const mockUseGetPackagePoliciesQuery = useGetPackagePoliciesQuery as jest.Mock; + + afterEach(() => { + mockUseGetPackagePoliciesQuery.mockReturnValue({ data: { items: [] } }); + }); + + it('shows opt-in warning (Case 3) when toggle is on, namespace not opted in, and other policies exist', async () => { + mockUseGetPackagePoliciesQuery.mockReturnValue({ + data: { items: [{ id: 'other-policy-1' }, { id: 'other-policy-2' }] }, + }); + renderResult = renderWithToggle({ + packagePolicyOverride: { namespace: 'staging' }, + namespaceCustomizationEnabled: true, + installedNamespaceCustomizationEnabledFor: [], + }); + await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); + await waitFor(() => { + expect( + renderResult.getByTestId('packagePolicyNamespaceCustomizationOptInImpactWarning') + ).toBeInTheDocument(); + }); + expect( + renderResult.queryByTestId('packagePolicyNamespaceCustomizationOptOutImpactWarning') + ).not.toBeInTheDocument(); + }); + + it('shows opt-out warning (Case 8) when toggle is off, namespace is opted in, and other policies exist', async () => { + mockUseGetPackagePoliciesQuery.mockReturnValue({ + data: { items: [{ id: 'other-policy-1' }] }, + }); + renderResult = renderWithToggle({ + packagePolicyOverride: { namespace: 'staging' }, + namespaceCustomizationEnabled: false, + installedNamespaceCustomizationEnabledFor: ['staging'], + }); + await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); + await waitFor(() => { + expect( + renderResult.getByTestId('packagePolicyNamespaceCustomizationOptOutImpactWarning') + ).toBeInTheDocument(); + }); + expect( + renderResult.queryByTestId('packagePolicyNamespaceCustomizationOptInImpactWarning') + ).not.toBeInTheDocument(); + }); + + it('shows no warning when toggle is on and namespace is already opted in (Case 4)', async () => { + mockUseGetPackagePoliciesQuery.mockReturnValue({ + data: { items: [{ id: 'other-policy-1' }] }, + }); + renderResult = renderWithToggle({ + packagePolicyOverride: { namespace: 'staging' }, + namespaceCustomizationEnabled: true, + installedNamespaceCustomizationEnabledFor: ['staging'], + }); + await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); + await waitFor(() => { + expect( + renderResult.queryByTestId('packagePolicyNamespaceCustomizationOptInImpactWarning') + ).not.toBeInTheDocument(); + expect( + renderResult.queryByTestId('packagePolicyNamespaceCustomizationOptOutImpactWarning') + ).not.toBeInTheDocument(); + }); + }); + + it('shows no warning when toggle is on and there are no other policies', async () => { + mockUseGetPackagePoliciesQuery.mockReturnValue({ data: { items: [] } }); + renderResult = renderWithToggle({ + packagePolicyOverride: { namespace: 'staging' }, + namespaceCustomizationEnabled: true, + installedNamespaceCustomizationEnabledFor: [], + }); + await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); + await waitFor(() => { + expect( + renderResult.queryByTestId('packagePolicyNamespaceCustomizationOptInImpactWarning') + ).not.toBeInTheDocument(); + }); + }); + + it('excludes the current policy id from the other-policies count', async () => { + mockUseGetPackagePoliciesQuery.mockReturnValue({ + data: { items: [{ id: 'current-policy' }] }, + }); + renderResult = testRenderer.render( + + ); + await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); + await waitFor(() => { + expect( + renderResult.queryByTestId('packagePolicyNamespaceCustomizationOptInImpactWarning') + ).not.toBeInTheDocument(); + }); + }); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx index fbc8703efbe19..d380554dd3bb9 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx @@ -23,10 +23,15 @@ import { EuiSelect, type EuiComboBoxOptionOption, EuiIconTip, + EuiSwitch, + EuiToolTip, useGeneratedHtmlId, } from '@elastic/eui'; + import styled from 'styled-components'; +import { isNamespaceAllowedByPrefixes } from '../../../../../../../../common/services'; +import { PACKAGE_POLICY_SAVED_OBJECT_TYPE, SO_SEARCH_LIMIT } from '../../../../../constants'; import { NamespaceComboBox } from '../../../../../../../components/namespace_combo_box'; import { CloudConnectorSetup } from '../../../../../../../components/cloud_connector'; @@ -41,6 +46,7 @@ import type { import { Loading } from '../../../../../components'; import { useGetEpmDatastreams, + useGetPackagePoliciesQuery, useStartServices, useVarGroupCloudConnector, } from '../../../../../hooks'; @@ -73,6 +79,12 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ noAdvancedToggle?: boolean; isAgentlessSelected?: boolean; agentPolicies?: AgentPolicy[]; + // Namespace-level customization toggle (only rendered when all of the following are provided). + namespaceCustomizationEnabled?: boolean; + onNamespaceCustomizationEnabledChange?: (enabled: boolean) => void; + installedNamespaceCustomizationEnabledFor?: string[]; + allowedNamespacePrefixes?: string[]; + packagePolicyId?: string; }> = memo( ({ namespacePlaceholder, @@ -85,6 +97,11 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ isEditPage = false, isAgentlessSelected = false, agentPolicies, + namespaceCustomizationEnabled, + onNamespaceCustomizationEnabledChange, + installedNamespaceCustomizationEnabledFor, + allowedNamespacePrefixes, + packagePolicyId, }) => { const { docLinks, cloud } = useStartServices(); const { enableVarGroups } = ExperimentalFeaturesService.get(); @@ -204,6 +221,58 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ // Output is also disabled when any parent agent policy is managed (e.g. Elastic Cloud Agent Policy). const isOutputDisabled = isManaged || agentPolicies?.some((p) => p.is_managed) === true; + // Namespace-level customization toggle visibility/state. + const showNamespaceCustomizationToggle = + onNamespaceCustomizationEnabledChange !== undefined && + installedNamespaceCustomizationEnabledFor !== undefined && + allowedNamespacePrefixes !== undefined; + + const currentNamespace = packagePolicy.namespace?.trim() ?? ''; + const namespacePrefixesForCheck = + allowedNamespacePrefixes && allowedNamespacePrefixes.length > 0 + ? allowedNamespacePrefixes + : null; + const isNamespacePrefixAllowed = currentNamespace + ? isNamespaceAllowedByPrefixes(currentNamespace, namespacePrefixesForCheck) + : true; + const isNamespaceCustomizationInputDisabled = + !currentNamespace || isManaged || !isNamespacePrefixAllowed; + + // When the namespace changes to one that can't use customization, auto-reset the toggle so + // stale "enabled" state doesn't persist across namespace edits or form reuse. + useEffect(() => { + if (isNamespaceCustomizationInputDisabled && namespaceCustomizationEnabled) { + onNamespaceCustomizationEnabledChange?.(false); + } + }, [ + isNamespaceCustomizationInputDisabled, + namespaceCustomizationEnabled, + onNamespaceCustomizationEnabledChange, + ]); + + // Whether the current namespace is already opted in to namespace-level customization. + const isOptedIn = !!installedNamespaceCustomizationEnabledFor?.includes(currentNamespace); + + // Query other policies for the same package + namespace to determine impact warnings. + // Only fires when a warning is possible: + // Case 3: toggle on + namespace not yet opted in → opting in may affect others + // Case 8: toggle off + namespace already opted in → opting out may affect others + const otherPoliciesQuery = useGetPackagePoliciesQuery( + { + perPage: SO_SEARCH_LIMIT, + page: 1, + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${packageInfo.name} and ${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.namespace:"${currentNamespace}"`, + }, + { + enabled: + showNamespaceCustomizationToggle && + !isNamespaceCustomizationInputDisabled && + !!namespaceCustomizationEnabled !== isOptedIn, + } + ); + const otherPoliciesCount = + otherPoliciesQuery.data?.items?.filter((item) => item.id !== packagePolicyId).length ?? 0; + return validationResults ? ( <> {isManaged && ( @@ -432,6 +501,122 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ /> + {/* Namespace-level customization toggle */} + {showNamespaceCustomizationToggle && ( + + + + onNamespaceCustomizationEnabledChange?.(e.target.checked) + } + /> + + + + + + {/* Case 3: toggle on, not yet opted in, others share namespace */} + {namespaceCustomizationEnabled && !isOptedIn && otherPoliciesCount > 0 && ( + <> + + + {currentNamespace}, + }} + /> + + + )} + {/* Case 8: toggle off, currently opted in, others share namespace */} + {!namespaceCustomizationEnabled && isOptedIn && otherPoliciesCount > 0 && ( + <> + + + {currentNamespace}, + }} + /> + + + )} + + )} + {/* Output */} {canUseOutputPerIntegration && ( diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/multi_page_layout/hooks/use_get_agent_policy_or_default.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/multi_page_layout/hooks/use_get_agent_policy_or_default.tsx index d46594ef6bb3b..4a17399146f68 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/multi_page_layout/hooks/use_get_agent_policy_or_default.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/multi_page_layout/hooks/use_get_agent_policy_or_default.tsx @@ -16,6 +16,7 @@ import { sendGetEnrollmentAPIKeys, useFleetStatus, } from '../../../../../../../hooks'; +import { useSpaceSettingsContext } from '../../../../../../../hooks/use_space_settings_context'; import type { AgentPolicy, EnrollmentAPIKey } from '../../../../../../../types'; @@ -43,6 +44,7 @@ const sendGetAgentPolicy = async (agentPolicyId: string) => { export function useGetAgentPolicyOrDefault(agentPolicyIdIn?: string) { const { spaceId, isSpaceAwarenessEnabled } = useFleetStatus(); + const { defaultNamespace } = useSpaceSettingsContext(); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(); const [agentPolicyResponse, setAgentPolicyResponse] = useState(); @@ -68,9 +70,10 @@ export function useGetAgentPolicyOrDefault(agentPolicyIdIn?: string) { name: i18n.translate('xpack.fleet.createPackagePolicy.firstAgentPolicyNameText', { defaultMessage: 'My first agent policy', }), + namespace: defaultNamespace, }) ), - [defaultFirstPolicyId] + [defaultFirstPolicyId, defaultNamespace] ); useEffect(() => { diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/apply_namespace_customization.test.ts b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/apply_namespace_customization.test.ts new file mode 100644 index 0000000000000..ee839e6ce38e3 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/apply_namespace_customization.test.ts @@ -0,0 +1,116 @@ +/* + * 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 { sendUpdatePackage } from '../../../../hooks'; + +import { applyNamespaceCustomizationChange } from './apply_namespace_customization'; + +jest.mock('../../../../hooks', () => ({ + sendUpdatePackage: jest.fn(), +})); + +const mockSendUpdatePackage = sendUpdatePackage as jest.Mock; + +const buildNotifications = () => ({ + toasts: { + addSuccess: jest.fn(), + addError: jest.fn(), + }, +}); + +describe('applyNamespaceCustomizationChange', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSendUpdatePackage.mockResolvedValue({ data: null, error: null }); + }); + + it('does nothing when namespace is empty', async () => { + const notifications = buildNotifications(); + await applyNamespaceCustomizationChange( + 'nginx', + '1.0.0', + '', + true, + [], + notifications as any, + 'Nginx' + ); + expect(mockSendUpdatePackage).not.toHaveBeenCalled(); + }); + + it('adds the namespace when enabling and it was not in the list', async () => { + const notifications = buildNotifications(); + await applyNamespaceCustomizationChange( + 'nginx', + '1.0.0', + 'production', + true, + ['staging'], + notifications as any, + 'Nginx' + ); + expect(mockSendUpdatePackage).toHaveBeenCalledWith('nginx', '1.0.0', { + namespace_customization_enabled_for: ['staging', 'production'], + }); + expect(notifications.toasts.addSuccess).toHaveBeenCalled(); + }); + + it('removes the namespace when disabling and it was in the list', async () => { + const notifications = buildNotifications(); + await applyNamespaceCustomizationChange( + 'nginx', + '1.0.0', + 'production', + false, + ['production', 'staging'], + notifications as any, + 'Nginx' + ); + expect(mockSendUpdatePackage).toHaveBeenCalledWith('nginx', '1.0.0', { + namespace_customization_enabled_for: ['staging'], + }); + }); + + it('is a no-op when the desired state matches the current state', async () => { + const notifications = buildNotifications(); + await applyNamespaceCustomizationChange( + 'nginx', + '1.0.0', + 'production', + true, + ['production'], + notifications as any, + 'Nginx' + ); + await applyNamespaceCustomizationChange( + 'nginx', + '1.0.0', + 'production', + false, + [], + notifications as any, + 'Nginx' + ); + expect(mockSendUpdatePackage).not.toHaveBeenCalled(); + }); + + it('shows an error toast when sendUpdatePackage fails', async () => { + const notifications = buildNotifications(); + mockSendUpdatePackage.mockResolvedValueOnce({ data: null, error: new Error('boom') }); + await applyNamespaceCustomizationChange( + 'nginx', + '1.0.0', + 'production', + true, + [], + notifications as any, + 'Nginx' + ); + expect(notifications.toasts.addError).toHaveBeenCalled(); + expect(notifications.toasts.addSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/apply_namespace_customization.ts b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/apply_namespace_customization.ts new file mode 100644 index 0000000000000..e3dba62ae114e --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/apply_namespace_customization.ts @@ -0,0 +1,70 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import type { NotificationsStart } from '@kbn/core/public'; + +import { sendUpdatePackage } from '../../../../hooks'; + +/** + * After saving a package policy, sync the package's `namespace_customization_enabled_for` list + * so that the policy's namespace is added (when the toggle is on) or removed (when it is off + * and no other opted-in reason exists). + * + * Best effort: on error a toast is shown but no exception is rethrown — the policy save itself + * has already succeeded. + */ +export async function applyNamespaceCustomizationChange( + pkgName: string, + pkgVersion: string, + namespace: string | undefined, + desiredEnabled: boolean, + installedEnabledFor: string[], + notifications: NotificationsStart, + packageTitle: string +): Promise { + const trimmed = namespace?.trim(); + if (!trimmed) { + return; + } + const isCurrentlyEnabled = installedEnabledFor.includes(trimmed); + + let nextEnabledFor: string[] | undefined; + if (desiredEnabled && !isCurrentlyEnabled) { + nextEnabledFor = [...installedEnabledFor, trimmed]; + } else if (!desiredEnabled && isCurrentlyEnabled) { + nextEnabledFor = installedEnabledFor.filter((ns) => ns !== trimmed); + } + + if (nextEnabledFor === undefined) { + return; + } + + const { error } = await sendUpdatePackage(pkgName, pkgVersion, { + namespace_customization_enabled_for: nextEnabledFor, + }); + + if (error) { + notifications.toasts.addError(error, { + title: i18n.translate('xpack.fleet.packagePolicy.namespaceCustomizationApplyErrorTitle', { + defaultMessage: 'Could not update namespace customization for {title}', + values: { title: packageTitle }, + }), + }); + return; + } + + notifications.toasts.addSuccess({ + title: i18n.translate('xpack.fleet.packagePolicy.namespaceCustomizationApplySuccessTitle', { + defaultMessage: 'Namespace customization updated', + }), + text: i18n.translate('xpack.fleet.packagePolicy.namespaceCustomizationApplySuccessText', { + defaultMessage: 'Applying namespace customization changes for {title}.', + values: { title: packageTitle }, + }), + }); +} diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/index.ts b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/index.ts index fa96adcafff5b..1aa63b2fa91b4 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/index.ts +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/index.ts @@ -6,6 +6,7 @@ */ export { isAdvancedVar } from './is_advanced_var'; +export { applyNamespaceCustomizationChange } from './apply_namespace_customization'; export type { YamlParseFn } from './has_invalid_but_required_var'; export { hasInvalidButRequiredVar } from './has_invalid_but_required_var'; export { diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx index b2d62fb4173f1..54691e032bb12 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useCallback, useEffect, useMemo, useState, Suspense } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState, Suspense } from 'react'; import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -82,6 +82,7 @@ import { computeDefaultVarGroupSelections, type VarGroupSelection, } from '../services/var_group_helpers'; +import { applyNamespaceCustomizationChange } from '../services/apply_namespace_customization'; import { generateNewAgentPolicyWithDefaults } from '../../../../../../../common/services/generate_new_agent_policy'; @@ -135,7 +136,7 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ } = useConfig(); const hasFleetAddAgentsPrivileges = useAuthz().fleet.addAgents; const fleetStatus = useFleetStatus(); - const { docLinks } = useStartServices(); + const { docLinks, notifications } = useStartServices(); const spaceSettings = useSpaceSettingsContext(); const [newAgentPolicy, setNewAgentPolicy] = useState( generateNewAgentPolicyWithDefaults({ @@ -290,6 +291,46 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ [setSelectedPolicyTab, setPolicyValidation, newAgentPolicy] ); + // Namespace-level customization toggle state. Defaults to disabled for new policies; the actual + // package update is deferred until policy save (see effect below). + const installedNamespaceCustomizationEnabledFor = useMemo(() => { + if (packageInfo && 'installationInfo' in packageInfo) { + return packageInfo.installationInfo?.namespace_customization_enabled_for ?? []; + } + return []; + }, [packageInfo]); + const [namespaceCustomizationEnabled, setNamespaceCustomizationEnabled] = + useState(false); + const namespaceCustomizationAppliedRef = useRef(undefined); + + // After policy save: sync the package's namespace_customization_enabled_for list (deferred update). + useEffect(() => { + if (!savedPackagePolicy || !packageInfo) { + return; + } + if (namespaceCustomizationAppliedRef.current === savedPackagePolicy.id) { + return; + } + namespaceCustomizationAppliedRef.current = savedPackagePolicy.id; + // Reset the toggle so stale "enabled" state doesn't carry over if the form is reused. + setNamespaceCustomizationEnabled(false); + void applyNamespaceCustomizationChange( + packageInfo.name, + packageInfo.version, + savedPackagePolicy.namespace, + namespaceCustomizationEnabled, + installedNamespaceCustomizationEnabledFor, + notifications, + packageInfo.title ?? packageInfo.name + ); + }, [ + savedPackagePolicy, + packageInfo, + namespaceCustomizationEnabled, + installedNamespaceCustomizationEnabledFor, + notifications, + ]); + // Retrieve agent count const agentPolicyIds = agentPolicies.map((policy) => policy.id); @@ -556,6 +597,10 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ submitAttempted={formState === 'INVALID'} isAgentlessSelected={isAgentlessSelected} agentPolicies={agentPolicies} + namespaceCustomizationEnabled={namespaceCustomizationEnabled} + onNamespaceCustomizationEnabledChange={setNamespaceCustomizationEnabled} + installedNamespaceCustomizationEnabledFor={installedNamespaceCustomizationEnabledFor} + allowedNamespacePrefixes={spaceSettings?.allowedNamespacePrefixes ?? []} /> {/* Show SetupTechnologySelector for all agentless integrations, including extension views, if agentless is default display as a separate step */} @@ -605,6 +650,8 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ varGroupSelections, setupTechnologySelector, useCheckableCardsForSetupTechnologySelector, + namespaceCustomizationEnabled, + installedNamespaceCustomizationEnabledFor, ] ); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx index 022cb5101602b..8a21be6019d20 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx @@ -29,6 +29,7 @@ import { useAuthz, sendBulkGetAgentPoliciesForRq, } from '../../../hooks'; +import { useSpaceSettingsContext } from '../../../../../hooks/use_space_settings_context'; import { useBreadcrumbs as useIntegrationsBreadcrumbs, useGetOnePackagePolicy, @@ -48,6 +49,7 @@ import { StepDefinePackagePolicy, } from '../create_package_policy_page/components'; import { + applyNamespaceCustomizationChange, computeDefaultVarGroupSelections, type VarGroupSelection, } from '../create_package_policy_page/services'; @@ -178,6 +180,33 @@ export const EditPackagePolicyForm = memo<{ const [isFirstLoad, setIsFirstLoad] = useState(true); const [newAgentPolicyName, setNewAgentPolicyName] = useState(); + // Namespace-level customization toggle state. Initialized once package info loads. + const { allowedNamespacePrefixes } = useSpaceSettingsContext(); + const installedNamespaceCustomizationEnabledFor = useMemo(() => { + if (packageInfo && 'installationInfo' in packageInfo) { + return packageInfo.installationInfo?.namespace_customization_enabled_for ?? []; + } + return []; + }, [packageInfo]); + const [namespaceCustomizationEnabled, setNamespaceCustomizationEnabled] = + useState(false); + const [namespaceCustomizationInitialized, setNamespaceCustomizationInitialized] = + useState(false); + useEffect(() => { + if (namespaceCustomizationInitialized || !packagePolicy.namespace || !packageInfo) { + return; + } + setNamespaceCustomizationEnabled( + installedNamespaceCustomizationEnabledFor.includes(packagePolicy.namespace.trim()) + ); + setNamespaceCustomizationInitialized(true); + }, [ + installedNamespaceCustomizationEnabledFor, + packagePolicy.namespace, + namespaceCustomizationInitialized, + packageInfo, + ]); + // make form dirty if new agent policy is selected useEffect(() => { if (newAgentPolicyName) { @@ -353,6 +382,17 @@ export const EditPackagePolicyForm = memo<{ : packagePolicy.policy_ids, }); if (!error) { + if (packageInfo) { + await applyNamespaceCustomizationChange( + packageInfo.name, + packageInfo.version, + packagePolicy.namespace, + namespaceCustomizationEnabled, + installedNamespaceCustomizationEnabledFor, + notifications, + packageInfo.title ?? packageInfo.name + ); + } setIsEdited(false); application.navigateToUrl(successRedirectPath); notifications.toasts.addSuccess({ @@ -464,6 +504,14 @@ export const EditPackagePolicyForm = memo<{ isEditPage={true} isAgentlessSelected={hasAgentlessAgentPolicy} agentPolicies={agentPolicies} + namespaceCustomizationEnabled={namespaceCustomizationEnabled} + onNamespaceCustomizationEnabledChange={(enabled) => { + setNamespaceCustomizationEnabled(enabled); + setIsEdited(true); + }} + installedNamespaceCustomizationEnabledFor={installedNamespaceCustomizationEnabledFor} + allowedNamespacePrefixes={allowedNamespacePrefixes} + packagePolicyId={packagePolicyId} /> )} @@ -519,6 +567,11 @@ export const EditPackagePolicyForm = memo<{ isUpgrade, validationResults, varGroupSelections, + namespaceCustomizationEnabled, + installedNamespaceCustomizationEnabledFor, + allowedNamespacePrefixes, + packagePolicyId, + setIsEdited, ] ); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/index.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/index.tsx index a7379ce8abff6..d5f6b1c0a0d72 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/index.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/index.tsx @@ -12,4 +12,5 @@ export { UpdateIcon } from './update_icon'; export { IntegrationPolicyCount } from './integration_policy_count'; export { IconPanel, LoadingIconPanel, MiniIcon } from './icon_panel'; export { KeepPoliciesUpToDateSwitch } from './keep_policies_up_to_date_switch'; +export { NamespaceCustomizationSection } from './namespace_customization_section'; export { BidirectionalIntegrationsBanner } from './bidirectional_integrations_callout'; diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.tsx new file mode 100644 index 0000000000000..273d05b0fa4dc --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.tsx @@ -0,0 +1,226 @@ +/* + * 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, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiComboBox, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { + isValidNamespace, + isNamespaceAllowedByPrefixes, +} from '../../../../../../../../common/services'; + +interface Props { + savedNamespaces: string[]; + allowedNamespacePrefixes: string[]; + disabled?: boolean; + isSubmitting?: boolean; + onSave: (next: string[]) => void; +} + +const toOptions = (values: string[]): Array> => + values.map((v) => ({ label: v, value: v })); + +const setsEqual = (a: string[], b: string[]): boolean => { + if (a.length !== b.length) return false; + const setB = new Set(b); + return a.every((v) => setB.has(v)); +}; + +export const NamespaceCustomizationSection: React.FC = ({ + savedNamespaces, + allowedNamespacePrefixes, + disabled = false, + isSubmitting = false, + onSave, +}) => { + const [draftNamespaces, setDraftNamespaces] = useState(savedNamespaces); + const [validationError, setValidationError] = useState(undefined); + + // Reset draft and clear error after a successful save (savedNamespaces prop changes). + useEffect(() => { + setDraftNamespaces(savedNamespaces); + setValidationError(undefined); + }, [savedNamespaces]); + + const prefixesForCheck = useMemo( + () => (allowedNamespacePrefixes.length > 0 ? allowedNamespacePrefixes : null), + [allowedNamespacePrefixes] + ); + + const isDirty = useMemo( + () => !setsEqual(draftNamespaces, savedNamespaces), + [draftNamespaces, savedNamespaces] + ); + + const selectedOptions = useMemo(() => toOptions(draftNamespaces), [draftNamespaces]); + + const handleCreate = useCallback( + (rawInput: string) => { + const newNamespace = rawInput.trim(); + if (!newNamespace) { + return; + } + if (draftNamespaces.includes(newNamespace)) { + return; + } + + const { valid, error } = isValidNamespace(newNamespace); + if (!valid) { + setValidationError(error); + return; + } + if (!isNamespaceAllowedByPrefixes(newNamespace, prefixesForCheck)) { + setValidationError( + i18n.translate( + 'xpack.fleet.integrations.settings.namespaceCustomization.notAllowedPrefixError', + { + defaultMessage: + 'Namespace must start with one of the allowed prefixes for this space: {prefixes}', + values: { prefixes: allowedNamespacePrefixes.join(', ') }, + } + ) + ); + return; + } + setValidationError(undefined); + setDraftNamespaces([...draftNamespaces, newNamespace]); + }, + [draftNamespaces, prefixesForCheck, allowedNamespacePrefixes] + ); + + const handleChange = useCallback((next: Array>) => { + setValidationError(undefined); + setDraftNamespaces(next.map((option) => option.value ?? option.label)); + }, []); + + const handleSave = useCallback(() => { + onSave(draftNamespaces); + }, [draftNamespaces, onSave]); + + const handleDiscard = useCallback(() => { + setDraftNamespaces(savedNamespaces); + setValidationError(undefined); + }, [savedNamespaces]); + + return ( + <> + +

+ +

+
+ + + + + + 0 && ( + + ) + } + > + + data-test-subj="epmSettings.namespaceCustomizationInput" + noSuggestions + isDisabled={disabled || isSubmitting} + isInvalid={!!validationError} + placeholder={i18n.translate( + 'xpack.fleet.integrations.settings.namespaceCustomization.placeholder', + { defaultMessage: 'Add a namespace' } + )} + selectedOptions={selectedOptions} + onCreateOption={handleCreate} + onChange={handleChange} + /> + + {(isDirty || isSubmitting) && ( + <> + + {isSubmitting ? ( + + + + + + + + + + + ) : ( + + + + + + + + + + + + + )} + + )} + + ); +}; diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.test.tsx index 664d1afb94e6b..e8acd3b9cfb99 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.test.tsx @@ -291,4 +291,57 @@ describe('SettingsPage', () => { expect(rollbackButton).toBeDisabled(); }); }); + + describe('namespace customization section', () => { + const installedPackageInfo: PackageInfo = { + ...basePackageInfo, + status: 'installed', + installationInfo: { + version: '1.3.0', + previous_version: '1.2.0', + install_source: 'registry', + install_status: 'installed', + verification_status: 'verified', + verification_key_id: null, + installed_kibana: [], + installed_es: [], + type: 'epm-package', + name: 'nginx', + namespace_customization_enabled_for: ['production'], + }, + } as PackageInfo; + + beforeEach(() => { + mockUseGetPackageInstallStatus.mockReturnValue(() => ({ + status: InstallStatus.installed, + version: '1.3.0', + })); + mockUseAuthz.mockReturnValue({ + fleet: { readSettings: true }, + integrations: { + installPackages: true, + writePackageSettings: true, + }, + }); + }); + + it('renders the section title and existing opted-in namespaces', () => { + const result = renderComponent(installedPackageInfo); + + expect(result.getByText('Namespace customization')).toBeInTheDocument(); + const input = result.getByTestId('epmSettings.namespaceCustomizationInput'); + expect(input).toBeInTheDocument(); + expect(result.getByText('production')).toBeInTheDocument(); + }); + + it('does not render the section when the package is not installed', () => { + mockUseGetPackageInstallStatus.mockReturnValue(() => ({ + status: InstallStatus.notInstalled, + version: null, + })); + + const result = renderComponent(basePackageInfo); + expect(result.queryByText('Namespace customization')).not.toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx index e99ef203712f9..311eeed894a52 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx @@ -43,7 +43,9 @@ import { } from '../../../../../constants'; import { SideBarColumn } from '../../../components/side_bar_column'; import { BulkActionContextProvider } from '../../installed_integrations/hooks/use_bulk_actions_context'; -import { KeepPoliciesUpToDateSwitch } from '../components'; +import { useSpaceSettingsContext } from '../../../../../../../hooks/use_space_settings_context'; + +import { KeepPoliciesUpToDateSwitch, NamespaceCustomizationSection } from '../components'; import { useChangelog } from '../hooks'; import { ExperimentalFeaturesService } from '../../../../../services'; @@ -160,8 +162,69 @@ export const SettingsPage: React.FC = memo( ); const updatePackageMutation = useUpdatePackageMutation(); + const updateNamespaceCustomizationMutation = useUpdatePackageMutation(); const { notifications } = useStartServices(); + const { allowedNamespacePrefixes } = useSpaceSettingsContext(); + + const installationInfo = + 'installationInfo' in packageInfo ? packageInfo.installationInfo : undefined; + + const namespaceCustomizationEnabledFor = useMemo( + () => installationInfo?.namespace_customization_enabled_for ?? [], + [installationInfo?.namespace_customization_enabled_for] + ); + + const handleNamespaceCustomizationChange = useCallback( + (next: string[]) => { + updateNamespaceCustomizationMutation.mutate( + { + pkgName: packageInfo.name, + pkgVersion: packageInfo.version, + body: { + namespace_customization_enabled_for: next, + }, + }, + { + onSuccess: () => { + notifications.toasts.addSuccess({ + title: i18n.translate('xpack.fleet.integrations.integrationSaved', { + defaultMessage: 'Integration settings saved', + }), + text: i18n.translate( + 'xpack.fleet.integrations.namespaceCustomizationSavedSuccess', + { + defaultMessage: 'Applying namespace customization changes for {title}.', + values: { title }, + } + ), + }); + }, + onError: (error) => { + notifications.toasts.addError(error, { + title: i18n.translate('xpack.fleet.integrations.integrationSavedError', { + defaultMessage: 'Error saving integration settings', + }), + toastMessage: i18n.translate( + 'xpack.fleet.integrations.namespaceCustomizationError', + { + defaultMessage: 'Error saving namespace customization for {title}', + values: { title }, + } + ), + }); + }, + } + ); + }, + [ + notifications.toasts, + packageInfo.name, + packageInfo.version, + title, + updateNamespaceCustomizationMutation, + ] + ); const shouldShowKeepPoliciesUpToDateSwitch = useMemo(() => { return KEEP_POLICIES_UP_TO_DATE_PACKAGES.some((pkg) => pkg.name === name); @@ -322,6 +385,19 @@ export const SettingsPage: React.FC = memo( )} + {installationInfo && ( + <> + + + + )} + {(updateAvailable || isUpgradingPackagePolicies) && ( <> namespace.startsWith(prefix)); -} - export async function validatePolicyNamespaceForSpace({ namespace, spaceId, From b24bda3e86233fd74da01f29b92345fc983438c5 Mon Sep 17 00:00:00 2001 From: jillguyonnet Date: Wed, 13 May 2026 17:49:49 +0200 Subject: [PATCH 2/9] Handle duplicates --- .../steps/step_define_package_policy.tsx | 2 +- .../namespace_customization_section.test.tsx | 150 ++++++++++++++++++ .../namespace_customization_section.tsx | 30 +++- 3 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.test.tsx diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx index d380554dd3bb9..07eb5ee116308 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx @@ -261,7 +261,7 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ { perPage: SO_SEARCH_LIMIT, page: 1, - kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${packageInfo.name} and ${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.namespace:"${currentNamespace}"`, + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:"${packageInfo.name}" and ${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.namespace:"${currentNamespace}"`, }, { enabled: diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.test.tsx new file mode 100644 index 0000000000000..525e94246b12a --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.test.tsx @@ -0,0 +1,150 @@ +/* + * 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 { act } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; + +import { createIntegrationsTestRendererMock } from '../../../../../../../mock'; + +import { NamespaceCustomizationSection } from './namespace_customization_section'; + +function renderSection( + props: Partial> = {} +) { + const renderer = createIntegrationsTestRendererMock(); + const onSave = jest.fn(); + return { + onSave, + ...renderer.render( + + ), + }; +} + +describe('NamespaceCustomizationSection', () => { + it('renders the title and an empty combo box when no namespaces are saved', () => { + const { getByText, getByTestId } = renderSection(); + expect(getByText('Namespace customization')).toBeInTheDocument(); + expect(getByTestId('epmSettings.namespaceCustomizationInput')).toBeInTheDocument(); + }); + + it('shows existing saved namespaces as selected options', () => { + const { getByText } = renderSection({ savedNamespaces: ['prod', 'qa'] }); + expect(getByText('prod')).toBeInTheDocument(); + expect(getByText('qa')).toBeInTheDocument(); + }); + + it('does not show Save/Discard buttons when the draft matches saved namespaces', () => { + const { queryByTestId } = renderSection({ savedNamespaces: ['prod'] }); + expect(queryByTestId('epmSettings.namespaceCustomizationSave')).not.toBeInTheDocument(); + expect(queryByTestId('epmSettings.namespaceCustomizationDiscard')).not.toBeInTheDocument(); + }); + + it('shows Save and Discard buttons after adding a namespace', async () => { + const { getByTestId, queryByTestId } = renderSection({ savedNamespaces: [] }); + + const input = getByTestId('epmSettings.namespaceCustomizationInput').querySelector('input')!; + await userEvent.type(input, 'prod'); + await userEvent.keyboard('{Enter}'); + + expect(getByTestId('epmSettings.namespaceCustomizationSave')).toBeInTheDocument(); + expect(getByTestId('epmSettings.namespaceCustomizationDiscard')).toBeInTheDocument(); + }); + + it('calls onSave with the new namespace list when Save is clicked', async () => { + const { getByTestId, onSave } = renderSection({ savedNamespaces: ['prod'] }); + + const input = getByTestId('epmSettings.namespaceCustomizationInput').querySelector('input')!; + await userEvent.type(input, 'qa'); + await userEvent.keyboard('{Enter}'); + await userEvent.click(getByTestId('epmSettings.namespaceCustomizationSave')); + + expect(onSave).toHaveBeenCalledWith(expect.arrayContaining(['prod', 'qa'])); + }); + + it('reverts to saved namespaces and hides buttons when Discard is clicked', async () => { + const { getByTestId, queryByTestId, queryByText } = renderSection({ + savedNamespaces: ['prod'], + }); + + const input = getByTestId('epmSettings.namespaceCustomizationInput').querySelector('input')!; + await userEvent.type(input, 'qa'); + await userEvent.keyboard('{Enter}'); + + await userEvent.click(getByTestId('epmSettings.namespaceCustomizationDiscard')); + + expect(queryByText('qa')).not.toBeInTheDocument(); + expect(queryByTestId('epmSettings.namespaceCustomizationSave')).not.toBeInTheDocument(); + expect(queryByTestId('epmSettings.namespaceCustomizationDiscard')).not.toBeInTheDocument(); + }); + + it('shows an error and does not add a namespace that violates prefix rules', async () => { + const { getByTestId, queryByText, queryByTestId } = renderSection({ + savedNamespaces: [], + allowedNamespacePrefixes: ['prod'], + }); + + const input = getByTestId('epmSettings.namespaceCustomizationInput').querySelector('input')!; + await userEvent.type(input, 'staging'); + await userEvent.keyboard('{Enter}'); + + expect(queryByText('staging')).not.toBeInTheDocument(); + // Draft was not modified, so Save/Discard buttons do not appear. + expect(queryByTestId('epmSettings.namespaceCustomizationSave')).not.toBeInTheDocument(); + // Validation error is shown in the form row. + expect(getByTestId('epmSettings.namespaceCustomizationInput')).toBeInTheDocument(); + }); + + it('shows a duplicate error in real-time as the user types a namespace already in the list', async () => { + const { getByTestId, getByText, queryAllByText } = renderSection({ + savedNamespaces: ['prod'], + }); + + const input = getByTestId('epmSettings.namespaceCustomizationInput').querySelector('input')!; + await userEvent.type(input, 'prod'); + + // Error appears while typing, before Enter is pressed. + expect(getByText('Namespace is already in the list.')).toBeInTheDocument(); + // The namespace is not duplicated in the list. + expect(queryAllByText('prod')).toHaveLength(1); + }); + + it('resets draft and clears errors when savedNamespaces prop changes (after external save)', async () => { + const { getByTestId, queryByTestId, rerender } = renderSection({ savedNamespaces: [] }); + + const input = getByTestId('epmSettings.namespaceCustomizationInput').querySelector('input')!; + await userEvent.type(input, 'staging!'); + await userEvent.keyboard('{Enter}'); + + act(() => { + rerender( + + ); + }); + + expect(queryByTestId('epmSettings.namespaceCustomizationSave')).not.toBeInTheDocument(); + }); + + it('shows the in-progress spinner when isSubmitting is true', () => { + const { getByTestId, queryByTestId } = renderSection({ + savedNamespaces: [], + isSubmitting: true, + }); + expect(getByTestId('epmSettings.namespaceCustomizationApplying')).toBeInTheDocument(); + expect(queryByTestId('epmSettings.namespaceCustomizationSave')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.tsx index 273d05b0fa4dc..5cbf261421bf5 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.tsx @@ -13,7 +13,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, - EuiIcon, + EuiLoadingSpinner, EuiSpacer, EuiText, EuiTitle, @@ -79,6 +79,12 @@ export const NamespaceCustomizationSection: React.FC = ({ return; } if (draftNamespaces.includes(newNamespace)) { + setValidationError( + i18n.translate( + 'xpack.fleet.integrations.settings.namespaceCustomization.duplicateError', + { defaultMessage: 'Namespace is already in the list.' } + ) + ); return; } @@ -106,6 +112,25 @@ export const NamespaceCustomizationSection: React.FC = ({ [draftNamespaces, prefixesForCheck, allowedNamespacePrefixes] ); + // EUI silently ignores Enter when the typed value matches a selected option, so + // onCreateOption never fires for duplicates. Detect them in real-time via onSearchChange. + const handleSearchChange = useCallback( + (value: string) => { + const trimmed = value.trim(); + if (trimmed && draftNamespaces.includes(trimmed)) { + setValidationError( + i18n.translate( + 'xpack.fleet.integrations.settings.namespaceCustomization.duplicateError', + { defaultMessage: 'Namespace is already in the list.' } + ) + ); + } else { + setValidationError(undefined); + } + }, + [draftNamespaces] + ); + const handleChange = useCallback((next: Array>) => { setValidationError(undefined); setDraftNamespaces(next.map((option) => option.value ?? option.label)); @@ -166,6 +191,7 @@ export const NamespaceCustomizationSection: React.FC = ({ selectedOptions={selectedOptions} onCreateOption={handleCreate} onChange={handleChange} + onSearchChange={handleSearchChange} /> {(isDirty || isSubmitting) && ( @@ -179,7 +205,7 @@ export const NamespaceCustomizationSection: React.FC = ({ > - + Date: Fri, 15 May 2026 16:18:22 +0200 Subject: [PATCH 3/9] Refactor, cleanup and update texts --- .../translations/translations/de-DE.json | 2 - .../translations/translations/fr-FR.json | 2 - .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - .../common/services/is_valid_namespace.ts | 34 ++--- .../steps/step_define_package_policy.test.tsx | 103 +++++++------ .../steps/step_define_package_policy.tsx | 96 ++++--------- .../steps/use_namespace_customization.ts | 136 ++++++++++++++++++ .../services/apply_namespace_customization.ts | 2 +- .../single_page_layout/index.tsx | 31 ++-- .../edit_package_policy_page/index.tsx | 35 +---- .../namespace_customization_section.tsx | 7 +- .../epm/screens/detail/settings/settings.tsx | 2 +- .../server/routes/data_streams/handlers.ts | 3 - 14 files changed, 258 insertions(+), 199 deletions(-) create mode 100644 x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/use_namespace_customization.ts diff --git a/x-pack/platform/plugins/private/translations/translations/de-DE.json b/x-pack/platform/plugins/private/translations/translations/de-DE.json index 65a96d10b8508..aaa48a3aa0a18 100644 --- a/x-pack/platform/plugins/private/translations/translations/de-DE.json +++ b/x-pack/platform/plugins/private/translations/translations/de-DE.json @@ -21759,8 +21759,6 @@ "xpack.fleet.multiTextInput.deleteRowButton": "Löschen Sie \"{fieldLabel}\" Eingang {index}", "xpack.fleet.namespaceValidation.invalidCharactersErrorMessage": "{type} enthält ungültige Zeichen", "xpack.fleet.namespaceValidation.lowercaseErrorMessage": "{type} muss kleingeschrieben werden", - "xpack.fleet.namespaceValidation.notAllowedPrefixError": "Namespace sollte mit {allowedNamespacePrefixes}beginnen", - "xpack.fleet.namespaceValidation.notAllowedPrefixesError": "Der Namespace sollte mit einem dieser Präfixe beginnen: {allowedNamespacePrefixes}", "xpack.fleet.namespaceValidation.requiredErrorMessage": "{type} ist erforderlich", "xpack.fleet.namespaceValidation.tooLongErrorMessage": "{type} darf nicht mehr als 100 Byte umfassen", "xpack.fleet.newEnrollmentKey.cancelButtonLabel": "Abbrechen", diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index 0b14410c20dad..3995266e511e2 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -21707,8 +21707,6 @@ "xpack.fleet.multiTextInput.deleteRowButton": "Supprimer l'entrée \"{fieldLabel}\" {index}", "xpack.fleet.namespaceValidation.invalidCharactersErrorMessage": "{type} contient des caractères non valides", "xpack.fleet.namespaceValidation.lowercaseErrorMessage": "{type} doit être en minuscules", - "xpack.fleet.namespaceValidation.notAllowedPrefixError": "Un espace de nom doit commencer par {allowedNamespacePrefixes}", - "xpack.fleet.namespaceValidation.notAllowedPrefixesError": "Un espace de nom doit commencer par l'un des préfixes suivants : {allowedNamespacePrefixes}", "xpack.fleet.namespaceValidation.requiredErrorMessage": "{type} est requis", "xpack.fleet.namespaceValidation.tooLongErrorMessage": "{type} ne peut pas dépasser 100 octets", "xpack.fleet.newEnrollmentKey.cancelButtonLabel": "Annuler", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index 0bfb1101312b4..9bc0bf444e202 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -21831,8 +21831,6 @@ "xpack.fleet.multiTextInput.deleteRowButton": "\"{fieldLabel}\"入力{index}を削除", "xpack.fleet.namespaceValidation.invalidCharactersErrorMessage": "{type}に無効な文字が含まれています", "xpack.fleet.namespaceValidation.lowercaseErrorMessage": "{type}は小文字で指定する必要があります", - "xpack.fleet.namespaceValidation.notAllowedPrefixError": "名前空間の先頭は{allowedNamespacePrefixes}にしてください", - "xpack.fleet.namespaceValidation.notAllowedPrefixesError": "名前空間の先頭は{allowedNamespacePrefixes}のプレフィックスのいずれかにしてください", "xpack.fleet.namespaceValidation.requiredErrorMessage": "{type}が必要です", "xpack.fleet.namespaceValidation.tooLongErrorMessage": "{type}は100バイト以下でなければなりません", "xpack.fleet.newEnrollmentKey.cancelButtonLabel": "キャンセル", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index 9d712789e1da9..231e65af47212 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -21834,8 +21834,6 @@ "xpack.fleet.multiTextInput.deleteRowButton": "删除“{fieldLabel}”输入 {index}", "xpack.fleet.namespaceValidation.invalidCharactersErrorMessage": "{type} 包含无效字符", "xpack.fleet.namespaceValidation.lowercaseErrorMessage": "{type} 必须为小写", - "xpack.fleet.namespaceValidation.notAllowedPrefixError": "命名空间应以 {allowedNamespacePrefixes} 开头", - "xpack.fleet.namespaceValidation.notAllowedPrefixesError": "命名空间应以这些前缀之一开头:{allowedNamespacePrefixes}", "xpack.fleet.namespaceValidation.requiredErrorMessage": "{type} 必填", "xpack.fleet.namespaceValidation.tooLongErrorMessage": "{type} 不能超过 100 个字节", "xpack.fleet.newEnrollmentKey.cancelButtonLabel": "取消", diff --git a/x-pack/platform/plugins/shared/fleet/common/services/is_valid_namespace.ts b/x-pack/platform/plugins/shared/fleet/common/services/is_valid_namespace.ts index 914d545b165ed..c588fde6e02b6 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/is_valid_namespace.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/is_valid_namespace.ts @@ -29,26 +29,20 @@ export function isValidNamespace( namespace.trim().startsWith(prefix) ); if (!matchesAnyPrefix) { - return allowedNamespacePrefixes.length === 1 - ? { - valid: false, - error: i18n.translate('xpack.fleet.namespaceValidation.notAllowedPrefixError', { - defaultMessage: 'Namespace should start with {allowedNamespacePrefixes}', - values: { - allowedNamespacePrefixes: allowedNamespacePrefixes[0], - }, - }), - } - : { - valid: false, - error: i18n.translate('xpack.fleet.namespaceValidation.notAllowedPrefixesError', { - defaultMessage: - 'Namespace should start with one of these prefixes {allowedNamespacePrefixes}', - values: { - allowedNamespacePrefixes: allowedNamespacePrefixes.join(', '), - }, - }), - }; + return { + valid: false, + error: i18n.translate('xpack.fleet.namespaceValidation.notAllowedPrefixError', { + defaultMessage: + 'Namespace should start with {count, plural, one {{allowedNamespacePrefixes}} other {one of these prefixes: {allowedNamespacePrefixes}}}', + values: { + count: allowedNamespacePrefixes.length, + allowedNamespacePrefixes: + allowedNamespacePrefixes.length === 1 + ? allowedNamespacePrefixes[0] + : allowedNamespacePrefixes.join(', '), + }, + }), + }; } } return { valid: true }; diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.test.tsx index 3fe46ceca6961..27d245a2d8909 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.test.tsx @@ -7,9 +7,9 @@ import React from 'react'; import { waitFor, act } from '@testing-library/react'; - import { userEvent } from '@testing-library/user-event'; +import { useSpaceSettingsContext } from '../../../../../../../hooks/use_space_settings_context'; import { getInheritedNamespace } from '../../../../../../../../common/services'; import type { TestRenderer } from '../../../../../../../mock'; @@ -34,6 +34,14 @@ jest.mock('../../../../../hooks', () => ({ useGetPackagePoliciesQuery: jest.fn().mockReturnValue({ data: { items: [] } }), })); +jest.mock('../../../../../../../hooks/use_space_settings_context', () => ({ + ...jest.requireActual('../../../../../../../hooks/use_space_settings_context'), + useSpaceSettingsContext: jest.fn().mockReturnValue({ + allowedNamespacePrefixes: [], + defaultNamespace: 'default', + }), +})); + describe('StepDefinePackagePolicy', () => { const packageInfo: PackageInfo = { name: 'apache', @@ -430,35 +438,40 @@ describe('StepDefinePackagePolicy', () => { }); describe('namespace customization toggle', () => { + const mockUseSpaceSettingsContext = useSpaceSettingsContext as jest.Mock; + const renderWithToggle = (overrides: { packagePolicyOverride?: Partial; - namespaceCustomizationEnabled?: boolean; - installedNamespaceCustomizationEnabledFor?: string[]; - allowedNamespacePrefixes?: string[]; + packageInfoOverride?: Partial; onNamespaceCustomizationEnabledChange?: (enabled: boolean) => void; + packagePolicyId?: string; }) => { const policy = { ...packagePolicy, ...(overrides.packagePolicyOverride ?? {}) }; + const info = { ...packageInfo, ...(overrides.packageInfoOverride ?? {}) } as PackageInfo; return testRenderer.render( ); }; - it('renders the toggle in advanced options when all props are provided', async () => { + beforeEach(() => { + mockUseSpaceSettingsContext.mockReturnValue({ + allowedNamespacePrefixes: [], + defaultNamespace: 'default', + }); + }); + + it('renders the toggle in advanced options when onNamespaceCustomizationEnabledChange is provided', async () => { renderResult = renderWithToggle({}); await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); await waitFor(() => { @@ -468,7 +481,7 @@ describe('StepDefinePackagePolicy', () => { }); }); - it('does not render the toggle when the wiring props are missing', async () => { + it('does not render the toggle when onNamespaceCustomizationEnabledChange is not provided', async () => { renderResult = testRenderer.render( { }); it('is disabled when the namespace fails prefix validation', async () => { + mockUseSpaceSettingsContext.mockReturnValue({ + allowedNamespacePrefixes: ['production'], + defaultNamespace: 'production', + }); renderResult = renderWithToggle({ packagePolicyOverride: { namespace: 'staging' }, - allowedNamespacePrefixes: ['production'], }); await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); await waitFor(() => { @@ -500,9 +516,12 @@ describe('StepDefinePackagePolicy', () => { }); it('is enabled when the namespace matches an allowed prefix', async () => { + mockUseSpaceSettingsContext.mockReturnValue({ + allowedNamespacePrefixes: ['production'], + defaultNamespace: 'production', + }); renderResult = renderWithToggle({ packagePolicyOverride: { namespace: 'production_west' }, - allowedNamespacePrefixes: ['production'], }); await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); await waitFor(() => { @@ -530,16 +549,17 @@ describe('StepDefinePackagePolicy', () => { mockUseGetPackagePoliciesQuery.mockReturnValue({ data: { items: [] } }); }); - it('shows opt-in warning (Case 3) when toggle is on, namespace not opted in, and other policies exist', async () => { + it('shows opt-in warning when toggle is turned on, namespace not opted in, and other policies exist', async () => { mockUseGetPackagePoliciesQuery.mockReturnValue({ data: { items: [{ id: 'other-policy-1' }, { id: 'other-policy-2' }] }, }); + // packageInfo has no installationInfo → namespace not opted in → toggle starts OFF renderResult = renderWithToggle({ packagePolicyOverride: { namespace: 'staging' }, - namespaceCustomizationEnabled: true, - installedNamespaceCustomizationEnabledFor: [], }); await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); + const toggle = await renderResult.findByTestId('packagePolicyNamespaceCustomizationToggle'); + await userEvent.click(toggle); // turn ON await waitFor(() => { expect( renderResult.getByTestId('packagePolicyNamespaceCustomizationOptInImpactWarning') @@ -550,16 +570,22 @@ describe('StepDefinePackagePolicy', () => { ).not.toBeInTheDocument(); }); - it('shows opt-out warning (Case 8) when toggle is off, namespace is opted in, and other policies exist', async () => { + it('shows opt-out warning when toggle is turned off, namespace is opted in, and other policies exist', async () => { mockUseGetPackagePoliciesQuery.mockReturnValue({ data: { items: [{ id: 'other-policy-1' }] }, }); + // installationInfo includes 'staging' → toggle initializes to ON renderResult = renderWithToggle({ packagePolicyOverride: { namespace: 'staging' }, - namespaceCustomizationEnabled: false, - installedNamespaceCustomizationEnabledFor: ['staging'], + packageInfoOverride: { + installationInfo: { + namespace_customization_enabled_for: ['staging'], + } as any, + }, }); await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); + const toggle = await renderResult.findByTestId('packagePolicyNamespaceCustomizationToggle'); + await userEvent.click(toggle); // turn OFF await waitFor(() => { expect( renderResult.getByTestId('packagePolicyNamespaceCustomizationOptOutImpactWarning') @@ -570,14 +596,18 @@ describe('StepDefinePackagePolicy', () => { ).not.toBeInTheDocument(); }); - it('shows no warning when toggle is on and namespace is already opted in (Case 4)', async () => { + it('shows no warning when namespace is already opted in and toggle stays on', async () => { mockUseGetPackagePoliciesQuery.mockReturnValue({ data: { items: [{ id: 'other-policy-1' }] }, }); + // installationInfo includes 'staging' → toggle initializes to ON → isOptedIn true → no warning renderResult = renderWithToggle({ packagePolicyOverride: { namespace: 'staging' }, - namespaceCustomizationEnabled: true, - installedNamespaceCustomizationEnabledFor: ['staging'], + packageInfoOverride: { + installationInfo: { + namespace_customization_enabled_for: ['staging'], + } as any, + }, }); await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); await waitFor(() => { @@ -590,14 +620,14 @@ describe('StepDefinePackagePolicy', () => { }); }); - it('shows no warning when toggle is on and there are no other policies', async () => { + it('shows no warning when toggle is turned on but there are no other policies', async () => { mockUseGetPackagePoliciesQuery.mockReturnValue({ data: { items: [] } }); renderResult = renderWithToggle({ packagePolicyOverride: { namespace: 'staging' }, - namespaceCustomizationEnabled: true, - installedNamespaceCustomizationEnabledFor: [], }); await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); + const toggle = await renderResult.findByTestId('packagePolicyNamespaceCustomizationToggle'); + await userEvent.click(toggle); // turn ON await waitFor(() => { expect( renderResult.queryByTestId('packagePolicyNamespaceCustomizationOptInImpactWarning') @@ -609,22 +639,13 @@ describe('StepDefinePackagePolicy', () => { mockUseGetPackagePoliciesQuery.mockReturnValue({ data: { items: [{ id: 'current-policy' }] }, }); - renderResult = testRenderer.render( - - ); + renderResult = renderWithToggle({ + packagePolicyOverride: { namespace: 'staging' }, + packagePolicyId: 'current-policy', + }); await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); + const toggle = await renderResult.findByTestId('packagePolicyNamespaceCustomizationToggle'); + await userEvent.click(toggle); // turn ON await waitFor(() => { expect( renderResult.queryByTestId('packagePolicyNamespaceCustomizationOptInImpactWarning') diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx index 07eb5ee116308..f8a09dbc590df 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx @@ -30,8 +30,6 @@ import { import styled from 'styled-components'; -import { isNamespaceAllowedByPrefixes } from '../../../../../../../../common/services'; -import { PACKAGE_POLICY_SAVED_OBJECT_TYPE, SO_SEARCH_LIMIT } from '../../../../../constants'; import { NamespaceComboBox } from '../../../../../../../components/namespace_combo_box'; import { CloudConnectorSetup } from '../../../../../../../components/cloud_connector'; @@ -46,7 +44,6 @@ import type { import { Loading } from '../../../../../components'; import { useGetEpmDatastreams, - useGetPackagePoliciesQuery, useStartServices, useVarGroupCloudConnector, } from '../../../../../hooks'; @@ -58,6 +55,7 @@ import { ExperimentalFeaturesService } from '../../../../../services'; import { PackagePolicyInputVarField, VarGroupSelector, useVarGroupSelections } from './components'; import { useOutputs } from './components/hooks'; +import { useNamespaceCustomization } from './use_namespace_customization'; // on smaller screens, fields should be displayed in one column const FormGroupResponsiveFields = styled(EuiDescribedFormGroup)` @@ -79,11 +77,8 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ noAdvancedToggle?: boolean; isAgentlessSelected?: boolean; agentPolicies?: AgentPolicy[]; - // Namespace-level customization toggle (only rendered when all of the following are provided). - namespaceCustomizationEnabled?: boolean; + // Namespace-level customization toggle (rendered when this callback is provided). onNamespaceCustomizationEnabledChange?: (enabled: boolean) => void; - installedNamespaceCustomizationEnabledFor?: string[]; - allowedNamespacePrefixes?: string[]; packagePolicyId?: string; }> = memo( ({ @@ -97,10 +92,7 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ isEditPage = false, isAgentlessSelected = false, agentPolicies, - namespaceCustomizationEnabled, onNamespaceCustomizationEnabledChange, - installedNamespaceCustomizationEnabledFor, - allowedNamespacePrefixes, packagePolicyId, }) => { const { docLinks, cloud } = useStartServices(); @@ -221,57 +213,23 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ // Output is also disabled when any parent agent policy is managed (e.g. Elastic Cloud Agent Policy). const isOutputDisabled = isManaged || agentPolicies?.some((p) => p.is_managed) === true; - // Namespace-level customization toggle visibility/state. - const showNamespaceCustomizationToggle = - onNamespaceCustomizationEnabledChange !== undefined && - installedNamespaceCustomizationEnabledFor !== undefined && - allowedNamespacePrefixes !== undefined; - - const currentNamespace = packagePolicy.namespace?.trim() ?? ''; - const namespacePrefixesForCheck = - allowedNamespacePrefixes && allowedNamespacePrefixes.length > 0 - ? allowedNamespacePrefixes - : null; - const isNamespacePrefixAllowed = currentNamespace - ? isNamespaceAllowedByPrefixes(currentNamespace, namespacePrefixesForCheck) - : true; - const isNamespaceCustomizationInputDisabled = - !currentNamespace || isManaged || !isNamespacePrefixAllowed; - - // When the namespace changes to one that can't use customization, auto-reset the toggle so - // stale "enabled" state doesn't persist across namespace edits or form reuse. - useEffect(() => { - if (isNamespaceCustomizationInputDisabled && namespaceCustomizationEnabled) { - onNamespaceCustomizationEnabledChange?.(false); - } - }, [ - isNamespaceCustomizationInputDisabled, + const { + showToggle: showNamespaceCustomizationToggle, + currentNamespace, + isPrefixAllowed: isNamespacePrefixAllowed, + isToggleDisabled: isNamespaceCustomizationInputDisabled, namespaceCustomizationEnabled, - onNamespaceCustomizationEnabledChange, - ]); - - // Whether the current namespace is already opted in to namespace-level customization. - const isOptedIn = !!installedNamespaceCustomizationEnabledFor?.includes(currentNamespace); - - // Query other policies for the same package + namespace to determine impact warnings. - // Only fires when a warning is possible: - // Case 3: toggle on + namespace not yet opted in → opting in may affect others - // Case 8: toggle off + namespace already opted in → opting out may affect others - const otherPoliciesQuery = useGetPackagePoliciesQuery( - { - perPage: SO_SEARCH_LIMIT, - page: 1, - kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:"${packageInfo.name}" and ${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.namespace:"${currentNamespace}"`, - }, - { - enabled: - showNamespaceCustomizationToggle && - !isNamespaceCustomizationInputDisabled && - !!namespaceCustomizationEnabled !== isOptedIn, - } - ); - const otherPoliciesCount = - otherPoliciesQuery.data?.items?.filter((item) => item.id !== packagePolicyId).length ?? 0; + showOptInImpactWarning, + showOptOutImpactWarning, + otherPoliciesCount, + handleToggleChange: handleNamespaceCustomizationToggleChange, + } = useNamespaceCustomization({ + packageInfo, + namespace: packagePolicy.namespace, + onEnabledChange: onNamespaceCustomizationEnabledChange, + isManaged: !!isManaged, + packagePolicyId, + }); return validationResults ? ( <> @@ -540,10 +498,10 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ 'xpack.fleet.createPackagePolicy.namespaceCustomization.label', { defaultMessage: 'Enable namespace-level customization' } )} - checked={!!namespaceCustomizationEnabled} + checked={namespaceCustomizationEnabled} disabled={isNamespaceCustomizationInputDisabled} onChange={(e) => - onNamespaceCustomizationEnabledChange?.(e.target.checked) + handleNamespaceCustomizationToggleChange(e.target.checked) } /> @@ -551,11 +509,10 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ - {/* Case 3: toggle on, not yet opted in, others share namespace */} - {namespaceCustomizationEnabled && !isOptedIn && otherPoliciesCount > 0 && ( + {showOptInImpactWarning && ( <> {currentNamespace}, @@ -584,8 +541,7 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ )} - {/* Case 8: toggle off, currently opted in, others share namespace */} - {!namespaceCustomizationEnabled && isOptedIn && otherPoliciesCount > 0 && ( + {showOptOutImpactWarning && ( <> {currentNamespace}, diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/use_namespace_customization.ts b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/use_namespace_customization.ts new file mode 100644 index 0000000000000..cf11bc0c2d93c --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/use_namespace_customization.ts @@ -0,0 +1,136 @@ +/* + * 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 { useCallback, useEffect, useMemo, useState } from 'react'; + +import { isNamespaceAllowedByPrefixes } from '../../../../../../../../common/services'; +import { PACKAGE_POLICY_SAVED_OBJECT_TYPE, SO_SEARCH_LIMIT } from '../../../../../constants'; +import { useGetPackagePoliciesQuery } from '../../../../../hooks'; +import { useSpaceSettingsContext } from '../../../../../../../hooks/use_space_settings_context'; +import type { PackageInfo } from '../../../../../types'; + +interface Params { + packageInfo: PackageInfo | undefined; + namespace: string | undefined; + onEnabledChange: ((enabled: boolean) => void) | undefined; + isManaged: boolean; + packagePolicyId: string | undefined; +} + +interface Result { + showToggle: boolean; + currentNamespace: string; + isPrefixAllowed: boolean; + isToggleDisabled: boolean; + namespaceCustomizationEnabled: boolean; + isOptedIn: boolean; + otherPoliciesCount: number; + showOptInImpactWarning: boolean; + showOptOutImpactWarning: boolean; + handleToggleChange: (enabled: boolean) => void; +} + +export function useNamespaceCustomization({ + packageInfo, + namespace, + onEnabledChange, + isManaged, + packagePolicyId, +}: Params): Result { + const { allowedNamespacePrefixes } = useSpaceSettingsContext(); + + const installedEnabledFor = useMemo( + () => + packageInfo && 'installationInfo' in packageInfo + ? packageInfo.installationInfo?.namespace_customization_enabled_for ?? [] + : [], + [packageInfo] + ); + + const showToggle = onEnabledChange !== undefined; + + const currentNamespace = namespace?.trim() ?? ''; + + const prefixesForCheck = + allowedNamespacePrefixes && allowedNamespacePrefixes.length > 0 + ? allowedNamespacePrefixes + : null; + + const isPrefixAllowed = currentNamespace + ? isNamespaceAllowedByPrefixes(currentNamespace, prefixesForCheck) + : true; + + const isToggleDisabled = !currentNamespace || isManaged || !isPrefixAllowed; + + const [namespaceCustomizationEnabled, setNamespaceCustomizationEnabledInternal] = + useState(false); + const [initialized, setInitialized] = useState(false); + + // Initialize toggle from installed state once packageInfo is available. + // The !packageInfo guard prevents the effect from marking as initialized + // before the async packageInfo load completes. + useEffect(() => { + if (initialized || !namespace || !packageInfo) { + return; + } + setNamespaceCustomizationEnabledInternal(installedEnabledFor.includes(namespace.trim())); + setInitialized(true); + }, [installedEnabledFor, namespace, initialized, packageInfo]); + + const handleToggleChange = useCallback( + (enabled: boolean) => { + setNamespaceCustomizationEnabledInternal(enabled); + onEnabledChange?.(enabled); + }, + [onEnabledChange] + ); + + // Auto-reset the toggle when the namespace changes to one that can't use customization, + // so stale "enabled" state doesn't persist across namespace edits or form reuse. + useEffect(() => { + if (isToggleDisabled && namespaceCustomizationEnabled) { + setNamespaceCustomizationEnabledInternal(false); + onEnabledChange?.(false); + } + }, [isToggleDisabled, namespaceCustomizationEnabled, onEnabledChange]); + + const isOptedIn = !!installedEnabledFor?.includes(currentNamespace); + + // Query other policies for the same package + namespace to determine impact warnings. + const otherPoliciesQuery = useGetPackagePoliciesQuery( + { + perPage: SO_SEARCH_LIMIT, + page: 1, + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:"${ + packageInfo?.name ?? '' + }" and ${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.namespace:"${currentNamespace}"`, + }, + { + enabled: + showToggle && + !!packageInfo && + !isToggleDisabled && + !!namespaceCustomizationEnabled !== isOptedIn, + } + ); + + const otherPoliciesCount = + otherPoliciesQuery.data?.items?.filter((item) => item.id !== packagePolicyId).length ?? 0; + + return { + showToggle, + currentNamespace, + isPrefixAllowed, + isToggleDisabled, + namespaceCustomizationEnabled, + isOptedIn, + otherPoliciesCount, + showOptInImpactWarning: !!namespaceCustomizationEnabled && !isOptedIn && otherPoliciesCount > 0, + showOptOutImpactWarning: !namespaceCustomizationEnabled && isOptedIn && otherPoliciesCount > 0, + handleToggleChange, + }; +} diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/apply_namespace_customization.ts b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/apply_namespace_customization.ts index e3dba62ae114e..8250d0f529e55 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/apply_namespace_customization.ts +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/apply_namespace_customization.ts @@ -63,7 +63,7 @@ export async function applyNamespaceCustomizationChange( defaultMessage: 'Namespace customization updated', }), text: i18n.translate('xpack.fleet.packagePolicy.namespaceCustomizationApplySuccessText', { - defaultMessage: 'Applying namespace customization changes for {title}.', + defaultMessage: 'Applying namespace index template changes for {title}.', values: { title: packageTitle }, }), }); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx index 54691e032bb12..f926451feff4e 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx @@ -291,16 +291,15 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ [setSelectedPolicyTab, setPolicyValidation, newAgentPolicy] ); - // Namespace-level customization toggle state. Defaults to disabled for new policies; the actual - // package update is deferred until policy save (see effect below). + // Namespace-level customization. Toggle state lives inside StepDefinePackagePolicy's hook; + // the parent only tracks the latest value via ref for the deferred post-save update. const installedNamespaceCustomizationEnabledFor = useMemo(() => { if (packageInfo && 'installationInfo' in packageInfo) { return packageInfo.installationInfo?.namespace_customization_enabled_for ?? []; } return []; }, [packageInfo]); - const [namespaceCustomizationEnabled, setNamespaceCustomizationEnabled] = - useState(false); + const namespaceCustomizationEnabledRef = useRef(false); const namespaceCustomizationAppliedRef = useRef(undefined); // After policy save: sync the package's namespace_customization_enabled_for list (deferred update). @@ -312,24 +311,19 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ return; } namespaceCustomizationAppliedRef.current = savedPackagePolicy.id; - // Reset the toggle so stale "enabled" state doesn't carry over if the form is reused. - setNamespaceCustomizationEnabled(false); + // Capture and reset the toggle value so stale state doesn't carry over if the form is reused. + const wasEnabled = namespaceCustomizationEnabledRef.current; + namespaceCustomizationEnabledRef.current = false; void applyNamespaceCustomizationChange( packageInfo.name, packageInfo.version, savedPackagePolicy.namespace, - namespaceCustomizationEnabled, + wasEnabled, installedNamespaceCustomizationEnabledFor, notifications, packageInfo.title ?? packageInfo.name ); - }, [ - savedPackagePolicy, - packageInfo, - namespaceCustomizationEnabled, - installedNamespaceCustomizationEnabledFor, - notifications, - ]); + }, [savedPackagePolicy, packageInfo, installedNamespaceCustomizationEnabledFor, notifications]); // Retrieve agent count const agentPolicyIds = agentPolicies.map((policy) => policy.id); @@ -597,10 +591,9 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ submitAttempted={formState === 'INVALID'} isAgentlessSelected={isAgentlessSelected} agentPolicies={agentPolicies} - namespaceCustomizationEnabled={namespaceCustomizationEnabled} - onNamespaceCustomizationEnabledChange={setNamespaceCustomizationEnabled} - installedNamespaceCustomizationEnabledFor={installedNamespaceCustomizationEnabledFor} - allowedNamespacePrefixes={spaceSettings?.allowedNamespacePrefixes ?? []} + onNamespaceCustomizationEnabledChange={(enabled) => { + namespaceCustomizationEnabledRef.current = enabled; + }} /> {/* Show SetupTechnologySelector for all agentless integrations, including extension views, if agentless is default display as a separate step */} @@ -650,8 +643,6 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ varGroupSelections, setupTechnologySelector, useCheckableCardsForSetupTechnologySelector, - namespaceCustomizationEnabled, - installedNamespaceCustomizationEnabledFor, ] ); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx index 8a21be6019d20..d376916eb6a4d 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState, useEffect, useCallback, useMemo, memo } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef, memo } from 'react'; import { useRouteMatch, useLocation } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -29,7 +29,6 @@ import { useAuthz, sendBulkGetAgentPoliciesForRq, } from '../../../hooks'; -import { useSpaceSettingsContext } from '../../../../../hooks/use_space_settings_context'; import { useBreadcrumbs as useIntegrationsBreadcrumbs, useGetOnePackagePolicy, @@ -180,34 +179,14 @@ export const EditPackagePolicyForm = memo<{ const [isFirstLoad, setIsFirstLoad] = useState(true); const [newAgentPolicyName, setNewAgentPolicyName] = useState(); - // Namespace-level customization toggle state. Initialized once package info loads. - const { allowedNamespacePrefixes } = useSpaceSettingsContext(); const installedNamespaceCustomizationEnabledFor = useMemo(() => { if (packageInfo && 'installationInfo' in packageInfo) { return packageInfo.installationInfo?.namespace_customization_enabled_for ?? []; } return []; }, [packageInfo]); - const [namespaceCustomizationEnabled, setNamespaceCustomizationEnabled] = - useState(false); - const [namespaceCustomizationInitialized, setNamespaceCustomizationInitialized] = - useState(false); - useEffect(() => { - if (namespaceCustomizationInitialized || !packagePolicy.namespace || !packageInfo) { - return; - } - setNamespaceCustomizationEnabled( - installedNamespaceCustomizationEnabledFor.includes(packagePolicy.namespace.trim()) - ); - setNamespaceCustomizationInitialized(true); - }, [ - installedNamespaceCustomizationEnabledFor, - packagePolicy.namespace, - namespaceCustomizationInitialized, - packageInfo, - ]); + const namespaceCustomizationEnabledRef = useRef(false); - // make form dirty if new agent policy is selected useEffect(() => { if (newAgentPolicyName) { setIsEdited(true); @@ -387,7 +366,7 @@ export const EditPackagePolicyForm = memo<{ packageInfo.name, packageInfo.version, packagePolicy.namespace, - namespaceCustomizationEnabled, + namespaceCustomizationEnabledRef.current, installedNamespaceCustomizationEnabledFor, notifications, packageInfo.title ?? packageInfo.name @@ -504,13 +483,10 @@ export const EditPackagePolicyForm = memo<{ isEditPage={true} isAgentlessSelected={hasAgentlessAgentPolicy} agentPolicies={agentPolicies} - namespaceCustomizationEnabled={namespaceCustomizationEnabled} onNamespaceCustomizationEnabledChange={(enabled) => { - setNamespaceCustomizationEnabled(enabled); + namespaceCustomizationEnabledRef.current = enabled; setIsEdited(true); }} - installedNamespaceCustomizationEnabledFor={installedNamespaceCustomizationEnabledFor} - allowedNamespacePrefixes={allowedNamespacePrefixes} packagePolicyId={packagePolicyId} /> )} @@ -567,9 +543,6 @@ export const EditPackagePolicyForm = memo<{ isUpgrade, validationResults, varGroupSelections, - namespaceCustomizationEnabled, - installedNamespaceCustomizationEnabledFor, - allowedNamespacePrefixes, packagePolicyId, setIsEdited, ] diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.tsx index 5cbf261421bf5..81a54ef274e30 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.tsx @@ -155,11 +155,10 @@ export const NamespaceCustomizationSection: React.FC = ({ /> - - + @@ -210,7 +209,7 @@ export const NamespaceCustomizationSection: React.FC = ({ diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx index 311eeed894a52..6ce9e610522dd 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx @@ -194,7 +194,7 @@ export const SettingsPage: React.FC = memo( text: i18n.translate( 'xpack.fleet.integrations.namespaceCustomizationSavedSuccess', { - defaultMessage: 'Applying namespace customization changes for {title}.', + defaultMessage: 'Applying namespace index template changes for {title}.', values: { title }, } ), diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/data_streams/handlers.ts b/x-pack/platform/plugins/shared/fleet/server/routes/data_streams/handlers.ts index 4542a881afc02..b99e89ee9e36e 100644 --- a/x-pack/platform/plugins/shared/fleet/server/routes/data_streams/handlers.ts +++ b/x-pack/platform/plugins/shared/fleet/server/routes/data_streams/handlers.ts @@ -323,7 +323,6 @@ export const getDeprecatedILMCheckHandler: RequestHandler = async (context, requ continue; } - // Case 1: Using deprecated policy but @lifecycle doesn't exist → show callout if (!lifecyclePolicy) { deprecatedILMPolicies.push({ policyName: deprecatedPolicyName, @@ -333,14 +332,12 @@ export const getDeprecatedILMCheckHandler: RequestHandler = async (context, requ continue; } - // Case 2: Both policies exist // Don't show callout if both are unmodified (version 1) - auto-migration will happen if (deprecatedPolicy.version === 1 && lifecyclePolicy.version === 1) { // Both unmodified, auto-migration will handle this, skip continue; } - // Case 3: At least one policy is modified (version > 1) → show callout if (deprecatedPolicy.version > 1 || lifecyclePolicy.version > 1) { deprecatedILMPolicies.push({ policyName: deprecatedPolicyName, From ccedd7e38e0669ff7bcd51fa0f2fb8389c56cef3 Mon Sep 17 00:00:00 2001 From: jillguyonnet Date: Fri, 15 May 2026 16:23:29 +0200 Subject: [PATCH 4/9] Fix type check --- .../detail/components/namespace_customization_section.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.test.tsx index 525e94246b12a..56421e498e6e9 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.test.tsx @@ -51,7 +51,7 @@ describe('NamespaceCustomizationSection', () => { }); it('shows Save and Discard buttons after adding a namespace', async () => { - const { getByTestId, queryByTestId } = renderSection({ savedNamespaces: [] }); + const { getByTestId } = renderSection({ savedNamespaces: [] }); const input = getByTestId('epmSettings.namespaceCustomizationInput').querySelector('input')!; await userEvent.type(input, 'prod'); From e75936c89dc6e8b7920c7181381becd8c36c8fc2 Mon Sep 17 00:00:00 2001 From: jillguyonnet Date: Fri, 15 May 2026 17:08:28 +0200 Subject: [PATCH 5/9] Fixes --- .../steps/step_define_package_policy.test.tsx | 21 +++++++++++++++++-- .../steps/step_define_package_policy.tsx | 4 ++-- .../steps/use_namespace_customization.ts | 16 +++++++++++--- .../services/apply_namespace_customization.ts | 9 +------- .../edit_package_policy_page/index.tsx | 6 ++++-- 5 files changed, 39 insertions(+), 17 deletions(-) diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.test.tsx index 27d245a2d8909..fadf6e71a0fa5 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.test.tsx @@ -443,7 +443,7 @@ describe('StepDefinePackagePolicy', () => { const renderWithToggle = (overrides: { packagePolicyOverride?: Partial; packageInfoOverride?: Partial; - onNamespaceCustomizationEnabledChange?: (enabled: boolean) => void; + onNamespaceCustomizationEnabledChange?: (enabled: boolean, isInit?: boolean) => void; packagePolicyId?: string; }) => { const policy = { ...packagePolicy, ...(overrides.packagePolicyOverride ?? {}) }; @@ -539,7 +539,24 @@ describe('StepDefinePackagePolicy', () => { await userEvent.click(renderResult.getByText('Advanced options').closest('button')!); const toggle = await renderResult.findByTestId('packagePolicyNamespaceCustomizationToggle'); await userEvent.click(toggle); - expect(onChange).toHaveBeenCalledWith(true); + // The last call should be the user's toggle (no isInit flag), not the init call. + expect(onChange).toHaveBeenLastCalledWith(true); + }); + + it('calls onNamespaceCustomizationEnabledChange with isInit=true when namespace is already opted in', async () => { + const onChange = jest.fn(); + renderResult = renderWithToggle({ + packagePolicyOverride: { namespace: 'staging' }, + packageInfoOverride: { + installationInfo: { + namespace_customization_enabled_for: ['staging'], + } as any, + }, + onNamespaceCustomizationEnabledChange: onChange, + }); + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith(true, true); + }); }); describe('impact warnings', () => { diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx index f8a09dbc590df..9fd0cc12cd957 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx @@ -78,7 +78,7 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ isAgentlessSelected?: boolean; agentPolicies?: AgentPolicy[]; // Namespace-level customization toggle (rendered when this callback is provided). - onNamespaceCustomizationEnabledChange?: (enabled: boolean) => void; + onNamespaceCustomizationEnabledChange?: (enabled: boolean, isInit?: boolean) => void; packagePolicyId?: string; }> = memo( ({ @@ -489,7 +489,7 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ 'Namespace-level customization cannot be changed on a managed integration policy.', } ) - : '' + : undefined } > void) | undefined; + // isInit is true when the call comes from programmatic initialization, not user interaction. + onEnabledChange: ((enabled: boolean, isInit?: boolean) => void) | undefined; isManaged: boolean; packagePolicyId: string | undefined; } @@ -73,13 +74,20 @@ export function useNamespaceCustomization({ // Initialize toggle from installed state once packageInfo is available. // The !packageInfo guard prevents the effect from marking as initialized // before the async packageInfo load completes. + // onEnabledChange is called with isInit=true so the parent can sync its ref without + // treating this as a user-driven edit (e.g. to avoid marking the form dirty). + // The initialized flag is intentional: once set it prevents the toggle from + // re-syncing when the user types a different namespace, which would override their + // explicit toggle choice. The auto-reset effect handles the disabled-namespace case. useEffect(() => { if (initialized || !namespace || !packageInfo) { return; } - setNamespaceCustomizationEnabledInternal(installedEnabledFor.includes(namespace.trim())); + const enabled = installedEnabledFor.includes(namespace.trim()); + setNamespaceCustomizationEnabledInternal(enabled); + onEnabledChange?.(enabled, true); setInitialized(true); - }, [installedEnabledFor, namespace, initialized, packageInfo]); + }, [installedEnabledFor, namespace, initialized, packageInfo, onEnabledChange]); const handleToggleChange = useCallback( (enabled: boolean) => { @@ -101,6 +109,8 @@ export function useNamespaceCustomization({ const isOptedIn = !!installedEnabledFor?.includes(currentNamespace); // Query other policies for the same package + namespace to determine impact warnings. + // Limited to SO_SEARCH_LIMIT results; deployments with more policies than that threshold + // may under-count. This only affects the display of the warning, not correctness of the save. const otherPoliciesQuery = useGetPackagePoliciesQuery( { perPage: SO_SEARCH_LIMIT, diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/apply_namespace_customization.ts b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/apply_namespace_customization.ts index 8250d0f529e55..58b3344698b83 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/apply_namespace_customization.ts +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/apply_namespace_customization.ts @@ -10,14 +10,7 @@ import type { NotificationsStart } from '@kbn/core/public'; import { sendUpdatePackage } from '../../../../hooks'; -/** - * After saving a package policy, sync the package's `namespace_customization_enabled_for` list - * so that the policy's namespace is added (when the toggle is on) or removed (when it is off - * and no other opted-in reason exists). - * - * Best effort: on error a toast is shown but no exception is rethrown — the policy save itself - * has already succeeded. - */ +// Best effort: errors show a toast but are not rethrown — the policy save has already succeeded. export async function applyNamespaceCustomizationChange( pkgName: string, pkgVersion: string, diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx index d376916eb6a4d..f9264161f829c 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx @@ -483,9 +483,11 @@ export const EditPackagePolicyForm = memo<{ isEditPage={true} isAgentlessSelected={hasAgentlessAgentPolicy} agentPolicies={agentPolicies} - onNamespaceCustomizationEnabledChange={(enabled) => { + onNamespaceCustomizationEnabledChange={(enabled, isInit) => { namespaceCustomizationEnabledRef.current = enabled; - setIsEdited(true); + if (!isInit) { + setIsEdited(true); + } }} packagePolicyId={packagePolicyId} /> From 1d89e83fa1693e5b101e461e03e10817f901df77 Mon Sep 17 00:00:00 2001 From: jillguyonnet Date: Mon, 18 May 2026 11:36:04 +0200 Subject: [PATCH 6/9] Update strings --- .../steps/step_define_package_policy.tsx | 20 +++++++++---------- .../services/apply_namespace_customization.ts | 5 ++--- .../namespace_customization_section.tsx | 6 +++--- .../screens/detail/settings/settings.test.tsx | 4 ++-- .../epm/screens/detail/settings/settings.tsx | 2 +- 5 files changed, 17 insertions(+), 20 deletions(-) diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx index 9fd0cc12cd957..ae5000b5b514b 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx @@ -77,7 +77,6 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ noAdvancedToggle?: boolean; isAgentlessSelected?: boolean; agentPolicies?: AgentPolicy[]; - // Namespace-level customization toggle (rendered when this callback is provided). onNamespaceCustomizationEnabledChange?: (enabled: boolean, isInit?: boolean) => void; packagePolicyId?: string; }> = memo( @@ -459,7 +458,6 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ />
- {/* Namespace-level customization toggle */} {showNamespaceCustomizationToggle && ( {showOptInImpactWarning && ( @@ -525,14 +523,14 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ 'xpack.fleet.createPackagePolicy.namespaceCustomization.optInImpactTitle', { defaultMessage: - 'Enabling namespace customization will affect {count, plural, one {# other policy} other {# other policies}}', + 'Enabling the namespace index template will affect {count, plural, one {# other policy} other {# other policies}}', values: { count: otherPoliciesCount }, } )} > {currentNamespace}, @@ -554,14 +552,14 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ 'xpack.fleet.createPackagePolicy.namespaceCustomization.optOutImpactTitle', { defaultMessage: - 'Disabling customization will affect {count, plural, one {# other policy} other {# other policies}}', + 'Disabling the namespace index template will affect {count, plural, one {# other policy} other {# other policies}}', values: { count: otherPoliciesCount }, } )} > {currentNamespace}, diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/apply_namespace_customization.ts b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/apply_namespace_customization.ts index 58b3344698b83..33cacae00ae27 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/apply_namespace_customization.ts +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/apply_namespace_customization.ts @@ -10,7 +10,6 @@ import type { NotificationsStart } from '@kbn/core/public'; import { sendUpdatePackage } from '../../../../hooks'; -// Best effort: errors show a toast but are not rethrown — the policy save has already succeeded. export async function applyNamespaceCustomizationChange( pkgName: string, pkgVersion: string, @@ -44,7 +43,7 @@ export async function applyNamespaceCustomizationChange( if (error) { notifications.toasts.addError(error, { title: i18n.translate('xpack.fleet.packagePolicy.namespaceCustomizationApplyErrorTitle', { - defaultMessage: 'Could not update namespace customization for {title}', + defaultMessage: 'Could not update namespace index template for {title}', values: { title: packageTitle }, }), }); @@ -53,7 +52,7 @@ export async function applyNamespaceCustomizationChange( notifications.toasts.addSuccess({ title: i18n.translate('xpack.fleet.packagePolicy.namespaceCustomizationApplySuccessTitle', { - defaultMessage: 'Namespace customization updated', + defaultMessage: 'Namespace index template updated', }), text: i18n.translate('xpack.fleet.packagePolicy.namespaceCustomizationApplySuccessText', { defaultMessage: 'Applying namespace index template changes for {title}.', diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.tsx index 81a54ef274e30..38e1c8714face 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.tsx @@ -151,14 +151,14 @@ export const NamespaceCustomizationSection: React.FC = ({

@@ -166,7 +166,7 @@ export const NamespaceCustomizationSection: React.FC = ({ isInvalid={!!validationError} error={validationError} label={i18n.translate('xpack.fleet.integrations.settings.namespaceCustomization.label', { - defaultMessage: 'Namespaces opted in for customization', + defaultMessage: 'Namespaces with a dedicated index template', })} helpText={ allowedNamespacePrefixes.length > 0 && ( diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.test.tsx index e8acd3b9cfb99..899738ea4742a 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.test.tsx @@ -328,7 +328,7 @@ describe('SettingsPage', () => { it('renders the section title and existing opted-in namespaces', () => { const result = renderComponent(installedPackageInfo); - expect(result.getByText('Namespace customization')).toBeInTheDocument(); + expect(result.getByText('Namespace index templates')).toBeInTheDocument(); const input = result.getByTestId('epmSettings.namespaceCustomizationInput'); expect(input).toBeInTheDocument(); expect(result.getByText('production')).toBeInTheDocument(); @@ -341,7 +341,7 @@ describe('SettingsPage', () => { })); const result = renderComponent(basePackageInfo); - expect(result.queryByText('Namespace customization')).not.toBeInTheDocument(); + expect(result.queryByText('Namespace index templates')).not.toBeInTheDocument(); }); }); }); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx index 6ce9e610522dd..a4ce207dba83a 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx @@ -208,7 +208,7 @@ export const SettingsPage: React.FC = memo( toastMessage: i18n.translate( 'xpack.fleet.integrations.namespaceCustomizationError', { - defaultMessage: 'Error saving namespace customization for {title}', + defaultMessage: 'Error updating namespace index templates for {title}', values: { title }, } ), From 902f4a8b75338bc648a182806c94851a0c7d20d5 Mon Sep 17 00:00:00 2001 From: jillguyonnet Date: Mon, 18 May 2026 12:16:42 +0200 Subject: [PATCH 7/9] Fix input validation UX --- .../namespace_customization_section.test.tsx | 58 ++++++--- .../namespace_customization_section.tsx | 116 ++++++++---------- 2 files changed, 92 insertions(+), 82 deletions(-) diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.test.tsx index 56421e498e6e9..7224dc1c1d10b 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { act } from '@testing-library/react'; +import { act, within } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { createIntegrationsTestRendererMock } from '../../../../../../../mock'; @@ -34,7 +34,7 @@ function renderSection( describe('NamespaceCustomizationSection', () => { it('renders the title and an empty combo box when no namespaces are saved', () => { const { getByText, getByTestId } = renderSection(); - expect(getByText('Namespace customization')).toBeInTheDocument(); + expect(getByText('Namespace index templates')).toBeInTheDocument(); expect(getByTestId('epmSettings.namespaceCustomizationInput')).toBeInTheDocument(); }); @@ -88,8 +88,8 @@ describe('NamespaceCustomizationSection', () => { expect(queryByTestId('epmSettings.namespaceCustomizationDiscard')).not.toBeInTheDocument(); }); - it('shows an error and does not add a namespace that violates prefix rules', async () => { - const { getByTestId, queryByText, queryByTestId } = renderSection({ + it('adds an invalid namespace as a pill, shows a validation error, and disables Save', async () => { + const { getByTestId, queryByText } = renderSection({ savedNamespaces: [], allowedNamespacePrefixes: ['prod'], }); @@ -98,24 +98,54 @@ describe('NamespaceCustomizationSection', () => { await userEvent.type(input, 'staging'); await userEvent.keyboard('{Enter}'); - expect(queryByText('staging')).not.toBeInTheDocument(); - // Draft was not modified, so Save/Discard buttons do not appear. - expect(queryByTestId('epmSettings.namespaceCustomizationSave')).not.toBeInTheDocument(); - // Validation error is shown in the form row. - expect(getByTestId('epmSettings.namespaceCustomizationInput')).toBeInTheDocument(); + // Pill is added even though it violates the prefix rule. + expect(queryByText('staging')).toBeInTheDocument(); + // Save/Discard are shown because the draft is dirty. + expect(getByTestId('epmSettings.namespaceCustomizationSave')).toBeInTheDocument(); + expect(getByTestId('epmSettings.namespaceCustomizationDiscard')).toBeInTheDocument(); + // Save is disabled until the invalid pill is removed. + expect(getByTestId('epmSettings.namespaceCustomizationSave')).toBeDisabled(); }); - it('shows a duplicate error in real-time as the user types a namespace already in the list', async () => { - const { getByTestId, getByText, queryAllByText } = renderSection({ + it('enables Save after removing an invalid pill when other changes remain', async () => { + // Start with prod saved; add a valid prodqa pill and an invalid staging pill. + // After removing staging, draft=[prod, prodqa] is still dirty — Save should be enabled. + const { getByTestId } = renderSection({ savedNamespaces: ['prod'], + allowedNamespacePrefixes: ['prod'], }); + const input = getByTestId('epmSettings.namespaceCustomizationInput').querySelector('input')!; + + await userEvent.type(input, 'prodqa'); + await userEvent.keyboard('{Enter}'); + await userEvent.type(input, 'staging'); + await userEvent.keyboard('{Enter}'); + + // Save is disabled due to the invalid 'staging' pill. + expect(getByTestId('epmSettings.namespaceCustomizationSave')).toBeDisabled(); + + // Remove the invalid 'staging' pill via its close button. + const combobox = getByTestId('epmSettings.namespaceCustomizationInput'); + const stagingPill = within(combobox) + .getAllByTestId('euiComboBoxPill') + .find((pill) => pill.textContent?.includes('staging'))!; + const removeStagingButton = within(stagingPill).getByRole('button'); + await userEvent.click(removeStagingButton); + + // Draft still differs from saved (prod-qa is new), so Save is shown and enabled. + expect(getByTestId('epmSettings.namespaceCustomizationSave')).toBeInTheDocument(); + expect(getByTestId('epmSettings.namespaceCustomizationSave')).not.toBeDisabled(); + }); + + it('does not add a duplicate namespace', async () => { + const { getByTestId, queryAllByText } = renderSection({ savedNamespaces: ['prod'] }); + const input = getByTestId('epmSettings.namespaceCustomizationInput').querySelector('input')!; await userEvent.type(input, 'prod'); + await userEvent.keyboard('{Enter}'); - // Error appears while typing, before Enter is pressed. - expect(getByText('Namespace is already in the list.')).toBeInTheDocument(); - // The namespace is not duplicated in the list. + // EUI prevents the duplicate from being added. expect(queryAllByText('prod')).toHaveLength(1); }); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.tsx index 38e1c8714face..9313c6a4acb8a 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.tsx @@ -35,9 +35,6 @@ interface Props { onSave: (next: string[]) => void; } -const toOptions = (values: string[]): Array> => - values.map((v) => ({ label: v, value: v })); - const setsEqual = (a: string[], b: string[]): boolean => { if (a.length !== b.length) return false; const setB = new Set(b); @@ -52,12 +49,9 @@ export const NamespaceCustomizationSection: React.FC = ({ onSave, }) => { const [draftNamespaces, setDraftNamespaces] = useState(savedNamespaces); - const [validationError, setValidationError] = useState(undefined); - // Reset draft and clear error after a successful save (savedNamespaces prop changes). useEffect(() => { setDraftNamespaces(savedNamespaces); - setValidationError(undefined); }, [savedNamespaces]); const prefixesForCheck = useMemo( @@ -70,69 +64,57 @@ export const NamespaceCustomizationSection: React.FC = ({ [draftNamespaces, savedNamespaces] ); - const selectedOptions = useMemo(() => toOptions(draftNamespaces), [draftNamespaces]); + const selectedOptions = useMemo( + () => + draftNamespaces.map((ns) => { + const { valid } = isValidNamespace(ns); + const isAllowed = isNamespaceAllowedByPrefixes(ns, prefixesForCheck); + return { + label: ns, + value: ns, + color: !valid || !isAllowed ? 'danger' : undefined, + } as EuiComboBoxOptionOption; + }), + [draftNamespaces, prefixesForCheck] + ); - const handleCreate = useCallback( - (rawInput: string) => { - const newNamespace = rawInput.trim(); - if (!newNamespace) { - return; - } - if (draftNamespaces.includes(newNamespace)) { - setValidationError( - i18n.translate( - 'xpack.fleet.integrations.settings.namespaceCustomization.duplicateError', - { defaultMessage: 'Namespace is already in the list.' } - ) + const validationErrors = useMemo(() => { + const seen = new Set(); + const errors: string[] = []; + for (const ns of draftNamespaces) { + const { valid, error } = isValidNamespace(ns); + if (!valid && error) { + if (!seen.has(error)) { + seen.add(error); + errors.push(error); + } + } else if (!isNamespaceAllowedByPrefixes(ns, prefixesForCheck)) { + const prefixError = i18n.translate( + 'xpack.fleet.integrations.settings.namespaceCustomization.notAllowedPrefixError', + { + defaultMessage: + 'Namespace must start with one of the allowed prefixes for this space: {prefixes}', + values: { prefixes: allowedNamespacePrefixes.join(', ') }, + } ); - return; + if (!seen.has(prefixError)) { + seen.add(prefixError); + errors.push(prefixError); + } } + } + return errors; + }, [draftNamespaces, prefixesForCheck, allowedNamespacePrefixes]); - const { valid, error } = isValidNamespace(newNamespace); - if (!valid) { - setValidationError(error); - return; - } - if (!isNamespaceAllowedByPrefixes(newNamespace, prefixesForCheck)) { - setValidationError( - i18n.translate( - 'xpack.fleet.integrations.settings.namespaceCustomization.notAllowedPrefixError', - { - defaultMessage: - 'Namespace must start with one of the allowed prefixes for this space: {prefixes}', - values: { prefixes: allowedNamespacePrefixes.join(', ') }, - } - ) - ); - return; - } - setValidationError(undefined); - setDraftNamespaces([...draftNamespaces, newNamespace]); - }, - [draftNamespaces, prefixesForCheck, allowedNamespacePrefixes] - ); + const hasValidationError = validationErrors.length > 0; - // EUI silently ignores Enter when the typed value matches a selected option, so - // onCreateOption never fires for duplicates. Detect them in real-time via onSearchChange. - const handleSearchChange = useCallback( - (value: string) => { - const trimmed = value.trim(); - if (trimmed && draftNamespaces.includes(trimmed)) { - setValidationError( - i18n.translate( - 'xpack.fleet.integrations.settings.namespaceCustomization.duplicateError', - { defaultMessage: 'Namespace is already in the list.' } - ) - ); - } else { - setValidationError(undefined); - } - }, - [draftNamespaces] - ); + const handleCreate = useCallback((rawInput: string) => { + const newNamespace = rawInput.trim(); + if (!newNamespace) return; + setDraftNamespaces((prev) => [...prev, newNamespace]); + }, []); const handleChange = useCallback((next: Array>) => { - setValidationError(undefined); setDraftNamespaces(next.map((option) => option.value ?? option.label)); }, []); @@ -142,7 +124,6 @@ export const NamespaceCustomizationSection: React.FC = ({ const handleDiscard = useCallback(() => { setDraftNamespaces(savedNamespaces); - setValidationError(undefined); }, [savedNamespaces]); return ( @@ -163,8 +144,8 @@ export const NamespaceCustomizationSection: React.FC = ({ = ({ data-test-subj="epmSettings.namespaceCustomizationInput" noSuggestions isDisabled={disabled || isSubmitting} - isInvalid={!!validationError} + isInvalid={hasValidationError} placeholder={i18n.translate( 'xpack.fleet.integrations.settings.namespaceCustomization.placeholder', { defaultMessage: 'Add a namespace' } @@ -190,7 +171,6 @@ export const NamespaceCustomizationSection: React.FC = ({ selectedOptions={selectedOptions} onCreateOption={handleCreate} onChange={handleChange} - onSearchChange={handleSearchChange} /> {(isDirty || isSubmitting) && ( @@ -220,7 +200,7 @@ export const NamespaceCustomizationSection: React.FC = ({ From efa312587471566a650e110b18a643219b718dbb Mon Sep 17 00:00:00 2001 From: jillguyonnet Date: Mon, 18 May 2026 12:53:28 +0200 Subject: [PATCH 8/9] Add Learn more links --- .../steps/step_define_package_policy.tsx | 16 +++++++++++++++- .../namespace_customization_section.tsx | 15 ++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx index ae5000b5b514b..d0b5199ec2986 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx @@ -507,7 +507,21 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ + + + ), + }} /> {showOptInImpactWarning && ( diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.tsx index 9313c6a4acb8a..3b213b6b7c180 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/components/namespace_customization_section.tsx @@ -13,6 +13,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, + EuiLink, EuiLoadingSpinner, EuiSpacer, EuiText, @@ -26,6 +27,7 @@ import { isValidNamespace, isNamespaceAllowedByPrefixes, } from '../../../../../../../../common/services'; +import { useStartServices } from '../../../../../hooks'; interface Props { savedNamespaces: string[]; @@ -48,6 +50,7 @@ export const NamespaceCustomizationSection: React.FC = ({ isSubmitting = false, onSave, }) => { + const { docLinks } = useStartServices(); const [draftNamespaces, setDraftNamespaces] = useState(savedNamespaces); useEffect(() => { @@ -139,7 +142,17 @@ export const NamespaceCustomizationSection: React.FC = ({ + + + ), + }} /> From ade914bb1f210d3c7abe9e98e10e213fc045e5a8 Mon Sep 17 00:00:00 2001 From: jillguyonnet Date: Mon, 18 May 2026 15:53:43 +0200 Subject: [PATCH 9/9] Fix unit test --- .../sections/epm/screens/detail/settings/settings.test.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.test.tsx index 899738ea4742a..0f738dcf72a61 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.test.tsx @@ -27,6 +27,13 @@ jest.mock('../../../../../hooks', () => { addSuccess: jest.fn(), }, }, + docLinks: { + links: { + fleet: { + datastreams: 'https://www.elastic.co/docs/reference/fleet/data-streams', + }, + }, + }, }), useUpgradePackagePolicyDryRunQuery: jest.fn().mockReturnValue({ data: null }), useUpdatePackageMutation: jest.fn().mockReturnValue({ mutate: jest.fn() }),