diff --git a/frontend/public/components/modals/__tests__/impersonate-user-modal-integration.spec.tsx b/frontend/public/components/modals/__tests__/impersonate-user-modal-integration.spec.tsx index 850531b327c..fea715635df 100644 --- a/frontend/public/components/modals/__tests__/impersonate-user-modal-integration.spec.tsx +++ b/frontend/public/components/modals/__tests__/impersonate-user-modal-integration.spec.tsx @@ -1,6 +1,6 @@ /** * Integration tests for ImpersonateUserModal - * Tests the modal integrated with Redux actions and state + * Tests the modal rendering and user interaction workflows */ import { render, screen, waitFor } from '@testing-library/react'; @@ -8,7 +8,6 @@ import userEvent from '@testing-library/user-event'; import { Provider } from 'react-redux'; import { createStore } from 'redux'; import { ProjectModel } from '@console/dynamic-plugin-sdk/src/models'; -import * as UIActions from '../../../actions/ui'; import type { GroupKind } from '../../../module/k8s'; import { useK8sWatchResource } from '../../utils/k8s-watch-hook'; import { ImpersonateUserModal } from '../impersonate-user-modal'; @@ -18,14 +17,52 @@ jest.mock('../../utils/k8s-watch-hook', () => ({ useK8sWatchResource: jest.fn(), })); -jest.mock('../../../actions/ui', () => ({ - startImpersonate: jest.fn(), - stopImpersonate: jest.fn(), -})); - +// Mock NsDropdown to avoid deep import chain (list-dropdown → useCreateNamespaceModal → CreateNamespaceModal → resource-link → k8s-models → plugins → loadSchema) jest.mock('../../utils/list-dropdown', () => ({ - ...jest.requireActual('../../utils/list-dropdown'), useProjectOrNamespaceModel: () => [ProjectModel, true], + NsDropdown: ({ + selectedKey, + onChange, + dataTest, + }: { + selectedKey?: string; + dataTest?: string; + onChange: (key: string, kind?: string, resource?: { metadata: { name: string } }) => void; + }) => ( + + ), +})); + +// Mock ResourceDropdown to avoid deep import chain (plugins.ts → loadSchema) +jest.mock('@console/shared/src/components/dropdown/ResourceDropdown', () => ({ + ResourceDropdown: ({ + selectedKey, + onChange, + dataTest, + disabled, + placeholder, + }: { + selectedKey?: string | null; + dataTest?: string; + disabled?: boolean; + placeholder?: string; + onChange: (key: string, name?: string, resource?: { metadata: { name: string } }) => void; + }) => ( + + ), })); const mockGroups: GroupKind[] = [ @@ -45,27 +82,21 @@ const mockGroups: GroupKind[] = [ describe('ImpersonateUserModal Integration Tests', () => { let mockStore: any; - let mockStartImpersonate: jest.Mock; beforeEach(() => { jest.clearAllMocks(); (useK8sWatchResource as jest.Mock).mockReturnValue([mockGroups, true, null]); - mockStartImpersonate = jest.fn(); - (UIActions.startImpersonate as jest.Mock).mockImplementation(mockStartImpersonate); - // Create a simple mock store const reducer = (state = {}) => state; mockStore = createStore(reducer); }); - describe('Form submission with Redux integration', () => { - it('should dispatch startImpersonate action with user only', async () => { + describe('Form submission', () => { + it('should call onImpersonate with user only', async () => { const user = userEvent.setup(); const onClose = jest.fn(); - const onImpersonate = jest.fn((username) => { - mockStartImpersonate('User', username); - }); + const onImpersonate = jest.fn(); render( @@ -82,20 +113,13 @@ describe('ImpersonateUserModal Integration Tests', () => { await waitFor(() => { expect(onImpersonate).toHaveBeenCalledWith('testuser', [], 'User'); - expect(mockStartImpersonate).toHaveBeenCalledWith('User', 'testuser'); }); }); - it('should dispatch startImpersonate action with user and groups', async () => { + it('should call onImpersonate with user and groups', async () => { const user = userEvent.setup(); const onClose = jest.fn(); - const onImpersonate = jest.fn((username, groups) => { - if (groups.length > 0) { - mockStartImpersonate('UserWithGroups', username, groups); - } else { - mockStartImpersonate('User', username); - } - }); + const onImpersonate = jest.fn(); render( @@ -120,9 +144,6 @@ describe('ImpersonateUserModal Integration Tests', () => { await waitFor(() => { expect(onImpersonate).toHaveBeenCalledWith('multiuser', ['developers'], 'User'); - expect(mockStartImpersonate).toHaveBeenCalledWith('UserWithGroups', 'multiuser', [ - 'developers', - ]); }); }); }); @@ -256,7 +277,7 @@ describe('ImpersonateUserModal Integration Tests', () => { }); }); - it('should show no results when filter matches nothing', async () => { + it('should show "Create" option when filter matches nothing in available groups', async () => { const user = userEvent.setup(); render( @@ -274,31 +295,88 @@ describe('ImpersonateUserModal Integration Tests', () => { // Type to filter with non-matching text await user.type(groupsInput, 'nonexistent'); - expect(await screen.findByText('No results found')).toBeVisible(); + // Should show "Create" option instead of just "No results found" + expect(await screen.findByText('Create "nonexistent"')).toBeVisible(); }); }); - describe('Error handling workflow', () => { - it('should show error when groups fail to load', async () => { - const error = new Error('Failed to fetch groups'); - (useK8sWatchResource as jest.Mock).mockReturnValue([[], false, error]); + describe('Direct Authentication / model-absent workflow', () => { + it('should allow group impersonation when Group model does not exist', async () => { + const error = new Error('Model does not exist'); + (useK8sWatchResource as jest.Mock).mockReturnValue([[], true, error]); + + const user = userEvent.setup(); + const onImpersonate = jest.fn(); render( - + , ); - expect(await screen.findByText('Failed to load groups')).toBeVisible(); + // Should NOT show error alert + expect(screen.queryByText('Failed to load groups')).not.toBeInTheDocument(); + + // Should show helper text for manual entry + expect( + screen.getByText('Type group names manually. Press Enter to add each group.'), + ).toBeInTheDocument(); + + // Enter username + const usernameInput = screen.getByTestId('username-input'); + await user.clear(usernameInput); + await user.type(usernameInput, 'oidc-user'); + + // Enter groups via free-form + const groupInput = screen.getByPlaceholderText('Enter groups'); + await user.click(groupInput); + await user.type(groupInput, 'oidc-admins{Enter}'); + await user.type(groupInput, 'oidc-devs{Enter}'); + + // Both groups should appear as chips + await waitFor(() => { + expect(screen.getByText('oidc-admins')).toBeInTheDocument(); + expect(screen.getByText('oidc-devs')).toBeInTheDocument(); + }); + + // Submit + const submitButton = screen.getByTestId('impersonate-button'); + await user.click(submitButton); + + await waitFor(() => { + expect(onImpersonate).toHaveBeenCalledWith( + 'oidc-user', + ['oidc-admins', 'oidc-devs'], + 'User', + ); + }); + }); + + it('should still allow impersonation without groups when model is absent', async () => { + const error = new Error('Model does not exist'); + (useK8sWatchResource as jest.Mock).mockReturnValue([[], true, error]); - // Should still allow impersonation without groups const ue = userEvent.setup(); + const onImpersonate = jest.fn(); + + render( + + + , + ); + const usernameInput = screen.getByTestId('username-input'); await ue.clear(usernameInput); await ue.type(usernameInput, 'erroruser'); const submitButton = screen.getByTestId('impersonate-button'); expect(submitButton).not.toBeDisabled(); + + await ue.click(submitButton); + + await waitFor(() => { + expect(onImpersonate).toHaveBeenCalledWith('erroruser', [], 'User'); + }); }); }); diff --git a/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx b/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx index c3fff12a8c7..ae71b5aefcd 100644 --- a/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx +++ b/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx @@ -233,17 +233,262 @@ describe('ImpersonateUserModal', () => { expect(screen.getByPlaceholderText('Enter groups')).toBeInTheDocument(); }); - it('should show error alert when groups fail to load', () => { - const error = new Error('Failed to load groups'); + it('should gracefully handle group load errors without showing error alert', () => { + const error = new Error('Model does not exist'); (useK8sWatchResource as jest.Mock).mockReturnValue([[], false, error]); render( , ); - // Check for alert with danger variant - const alerts = screen.getAllByText('Failed to load groups'); - expect(alerts.length).toBeGreaterThan(0); + // Should NOT show error alert — free-form entry is available instead + expect(screen.queryByText('Failed to load groups')).not.toBeInTheDocument(); + // Should show helper text for manual entry + expect( + screen.getByText('Type group names manually. Press Enter to add each group.'), + ).toBeInTheDocument(); + }); + }); + + describe('Free-form Group Entry', () => { + it('should add a group on Enter key press', async () => { + const user = userEvent.setup(); + // Groups model unavailable + const error = new Error('Model does not exist'); + (useK8sWatchResource as jest.Mock).mockReturnValue([[], true, error]); + + render( + , + ); + + const groupInput = screen.getByPlaceholderText('Enter groups'); + await user.click(groupInput); + await user.type(groupInput, 'my-custom-group{Enter}'); + + // Group chip should appear + await waitFor(() => { + expect(screen.getByText('my-custom-group')).toBeInTheDocument(); + }); + }); + + it('should add multiple free-form groups', async () => { + const user = userEvent.setup(); + const error = new Error('Model does not exist'); + (useK8sWatchResource as jest.Mock).mockReturnValue([[], true, error]); + + render( + , + ); + + const groupInput = screen.getByPlaceholderText('Enter groups'); + await user.click(groupInput); + await user.type(groupInput, 'group-a{Enter}'); + await user.type(groupInput, 'group-b{Enter}'); + + await waitFor(() => { + expect(screen.getByText('group-a')).toBeInTheDocument(); + expect(screen.getByText('group-b')).toBeInTheDocument(); + }); + }); + + it('should not add duplicate groups on Enter', async () => { + const user = userEvent.setup(); + const error = new Error('Model does not exist'); + (useK8sWatchResource as jest.Mock).mockReturnValue([[], true, error]); + + render( + , + ); + + const groupInput = screen.getByPlaceholderText('Enter groups'); + await user.click(groupInput); + await user.type(groupInput, 'my-group{Enter}'); + await user.type(groupInput, 'my-group{Enter}'); + + await waitFor(() => { + // eslint-disable-next-line testing-library/no-node-access -- checking chip count + const chips = document.querySelectorAll('.pf-v6-c-label'); + expect(chips.length).toBe(1); + }); + }); + + it('should show "Create" option in dropdown for new group name', async () => { + const user = userEvent.setup(); + render( + , + ); + + const groupInput = screen.getByPlaceholderText('Enter groups'); + await user.click(groupInput); + await user.type(groupInput, 'new-custom-group'); + + await waitFor(() => { + expect(screen.getByText('Create "new-custom-group"')).toBeInTheDocument(); + }); + }); + + it('should add group via "Create" option click', async () => { + const user = userEvent.setup(); + render( + , + ); + + const groupInput = screen.getByPlaceholderText('Enter groups'); + await user.click(groupInput); + await user.type(groupInput, 'new-custom-group'); + + const createOption = await screen.findByText('Create "new-custom-group"'); + await user.click(createOption); + + await waitFor(() => { + // eslint-disable-next-line testing-library/no-node-access -- checking chip appearance + const chips = document.querySelectorAll('.pf-v6-c-label'); + expect(chips.length).toBe(1); + }); + }); + + it('should submit free-form groups with onImpersonate', async () => { + const user = userEvent.setup(); + const error = new Error('Model does not exist'); + (useK8sWatchResource as jest.Mock).mockReturnValue([[], true, error]); + + render( + , + ); + + const usernameInput = screen.getByTestId('username-input'); + await user.clear(usernameInput); + await user.type(usernameInput, 'testuser'); + + const groupInput = screen.getByPlaceholderText('Enter groups'); + await user.click(groupInput); + await user.type(groupInput, 'oidc-admins{Enter}'); + await user.type(groupInput, 'oidc-developers{Enter}'); + + const submitButton = screen.getByTestId('impersonate-button'); + await user.click(submitButton); + + await waitFor(() => { + expect(mockOnImpersonate).toHaveBeenCalledWith( + 'testuser', + ['oidc-admins', 'oidc-developers'], + 'User', + ); + }); + }); + + it('should not add a group via Enter when it case-insensitively matches an available group', async () => { + const user = userEvent.setup(); + render( + , + ); + + const usernameInput = screen.getByTestId('username-input'); + await user.clear(usernameInput); + await user.type(usernameInput, 'testuser'); + + // Type "Admins" (differs in case from available "admins" group) + const groupInput = screen.getByPlaceholderText('Enter groups'); + await user.click(groupInput); + await user.type(groupInput, 'Admins{Enter}'); + + // Submit — "Admins" should NOT have been added as a free-form group + const submitButton = screen.getByTestId('impersonate-button'); + await user.click(submitButton); + + await waitFor(() => { + expect(mockOnImpersonate).toHaveBeenCalledWith('testuser', [], 'User'); + }); + }); + + it('should allow Select all to toggle correctly after adding a freeform group', async () => { + const user = userEvent.setup(); + render( + , + ); + + // First select all API groups (known working pattern) + const groupInput = screen.getByPlaceholderText('Enter groups'); + await user.click(groupInput); + expect(await screen.findByText('Select all')).toBeVisible(); + await user.click(screen.getByText('Select all')); + + // Add a freeform group via Create option + await user.type(groupInput, 'custom-freeform'); + const createOption = await screen.findByText('Create "custom-freeform"'); + await user.click(createOption); + + // Verify freeform group chip appears + await waitFor(() => { + expect(screen.getByText('custom-freeform')).toBeInTheDocument(); + }); + + // Submit and check all groups (freeform + API) are included + const usernameInput = screen.getByTestId('username-input'); + await user.clear(usernameInput); + await user.type(usernameInput, 'testuser'); + await user.click(screen.getByTestId('impersonate-button')); + + await waitFor(() => { + expect(mockOnImpersonate).toHaveBeenCalledWith( + 'testuser', + expect.arrayContaining(['custom-freeform', 'admins', 'developers', 'testers']), + 'User', + ); + expect(mockOnImpersonate.mock.calls[0][1]).toHaveLength(4); + }); + }); + + it('should keep freeform group when removing API groups via chip close buttons', async () => { + const user = userEvent.setup(); + render( + , + ); + + // Select an API group + const groupInput = screen.getByPlaceholderText('Enter groups'); + await user.click(groupInput); + await user.click(await screen.findByText('admins')); + + // Add a freeform group via Create option + await user.type(groupInput, 'custom-freeform'); + const createOption = await screen.findByText('Create "custom-freeform"'); + await user.click(createOption); + + // Verify both chips exist + await waitFor(() => { + expect(screen.getByText('custom-freeform')).toBeInTheDocument(); + }); + + // Remove the API group via chip close button + await user.click(screen.getByRole('button', { name: /close.*admins/i })); + + // Submit — should only have freeform group + const usernameInput = screen.getByTestId('username-input'); + await user.clear(usernameInput); + await user.type(usernameInput, 'testuser'); + await user.click(screen.getByTestId('impersonate-button')); + + await waitFor(() => { + expect(mockOnImpersonate).toHaveBeenCalledWith('testuser', ['custom-freeform'], 'User'); + }); + }); + + it('should show hint text when model unavailable and no text typed', async () => { + const user = userEvent.setup(); + const error = new Error('Model does not exist'); + (useK8sWatchResource as jest.Mock).mockReturnValue([[], true, error]); + + render( + , + ); + + const groupInput = screen.getByPlaceholderText('Enter groups'); + await user.click(groupInput); + + await waitFor(() => { + expect(screen.getByText('Type a group name and press Enter')).toBeInTheDocument(); + }); }); }); diff --git a/frontend/public/components/modals/impersonate-user-modal.tsx b/frontend/public/components/modals/impersonate-user-modal.tsx index c79000bc435..3080d749134 100644 --- a/frontend/public/components/modals/impersonate-user-modal.tsx +++ b/frontend/public/components/modals/impersonate-user-modal.tsx @@ -1,4 +1,4 @@ -import type { FC, Ref, MouseEvent } from 'react'; +import type { FC, KeyboardEvent, Ref, MouseEvent } from 'react'; import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import type { MenuToggleElement } from '@patternfly/react-core'; import { @@ -38,6 +38,7 @@ import { useK8sWatchResource } from '../utils/k8s-watch-hook'; import { NsDropdown, useProjectOrNamespaceModel } from '../utils/list-dropdown'; const SELECT_ALL_KEY = '__select_all__'; +const CREATE_KEY = '__create__'; const MAX_VISIBLE_CHIPS = 5; type ImpersonateSubjectKind = 'User' | 'ServiceAccount'; @@ -94,13 +95,16 @@ export const ImpersonateUserModal: FC = ({ isList: true, }); + // Whether groups are available from the API (model exists and loaded successfully) + const groupsAvailable = groupsLoaded && !groupsLoadError; + // Extract group names from the API response const availableGroups = useMemo(() => { - if (!groupsLoaded || groupsLoadError) { + if (!groupsAvailable) { return []; } return groups.map((group) => group.metadata.name).sort(); - }, [groups, groupsLoaded, groupsLoadError]); + }, [groups, groupsAvailable]); // Fetch available service accounts from the selected namespace. // Pass `null` until a namespace is chosen to avoid a cluster-wide watch. @@ -160,21 +164,66 @@ export const ImpersonateUserModal: FC = ({ ); }, [groupSearchFilter, availableGroups]); + // Check if typed text can be created as a new group entry + const isCreatableGroup = useMemo(() => { + const trimmed = groupSearchFilter.trim(); + if (!trimmed) { + return false; + } + // Don't show "Create" if it exactly matches an existing available group or is already selected + const alreadyExists = availableGroups.some((g) => g.toLowerCase() === trimmed.toLowerCase()); + const alreadySelected = selectedGroups.some((g) => g.toLowerCase() === trimmed.toLowerCase()); + return !alreadyExists && !alreadySelected; + }, [groupSearchFilter, availableGroups, selectedGroups]); + + // Add a free-form group name (case-insensitive duplicate check) + const handleCreateGroup = useCallback( + (groupName: string) => { + const trimmed = groupName.trim(); + if (!trimmed) { + return; + } + const lowerTrimmed = trimmed.toLowerCase(); + const alreadyExists = + availableGroups.some((g) => g.toLowerCase() === lowerTrimmed) || + selectedGroups.some((g) => g.toLowerCase() === lowerTrimmed); + if (!alreadyExists) { + setSelectedGroups([...selectedGroups, trimmed]); + setGroupSearchFilter(''); + } + }, + [selectedGroups, availableGroups], + ); + + // Check if all filtered groups are selected (needed before handleSelectAll) + const areAllFilteredGroupsSelected = useMemo(() => { + if (filteredGroups.length === 0) { + return false; + } + return filteredGroups.every((group) => selectedGroups.includes(group)); + }, [filteredGroups, selectedGroups]); + const handleSelectAll = useCallback(() => { - if (selectedGroups.length === filteredGroups.length) { - // If all filtered groups are selected, deselect all + if (areAllFilteredGroupsSelected) { + // Deselect all filtered groups (preserve freeform groups not in filteredGroups) setSelectedGroups(selectedGroups.filter((g) => !filteredGroups.includes(g))); } else { - // Select all filtered groups (merge with existing selections from other filters) + // Select all filtered groups (merge with existing selections including freeform) const newSelections = new Set([...selectedGroups, ...filteredGroups]); setSelectedGroups(Array.from(newSelections)); } - }, [selectedGroups, filteredGroups]); + }, [selectedGroups, filteredGroups, areAllFilteredGroupsSelected]); const handleGroupSelect = useCallback( (_event: MouseEvent | undefined, value: string | number) => { const group = value as string; + // Handle "Create" option + if (group === CREATE_KEY) { + handleCreateGroup(groupSearchFilter); + return; + } + // Handle "Select all" option if (group === SELECT_ALL_KEY) { handleSelectAll(); @@ -190,13 +239,23 @@ export const ImpersonateUserModal: FC = ({ } // Keep dropdown open - don't call setIsGroupSelectOpen(false) }, - [selectedGroups, handleSelectAll], + [selectedGroups, handleSelectAll, handleCreateGroup, groupSearchFilter], ); const handleGroupRemove = (groupToRemove: string) => { setSelectedGroups(selectedGroups.filter((g) => g !== groupToRemove)); }; + // Handle Enter key to add free-form group (uses same predicate as Create option) + const handleGroupInputKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + if (isCreatableGroup) { + handleCreateGroup(groupSearchFilter); + } + } + }; + const validateForm = (): boolean => { setUsernameError(''); setServiceAccountNamespaceError(''); @@ -265,14 +324,6 @@ export const ImpersonateUserModal: FC = ({ const visibleGroups = showAllGroups ? selectedGroups : selectedGroups.slice(0, MAX_VISIBLE_CHIPS); const remainingCount = selectedGroups.length - MAX_VISIBLE_CHIPS; - // Check if all filtered groups are selected - const areAllFilteredGroupsSelected = useMemo(() => { - if (filteredGroups.length === 0) { - return false; - } - return filteredGroups.every((group) => selectedGroups.includes(group)); - }, [filteredGroups, selectedGroups]); - const textInputGroupRef = useRef(null); const isImpersonateDisabled = @@ -298,12 +349,14 @@ export const ImpersonateUserModal: FC = ({ setIsGroupSelectOpen(true); } }} + onKeyDown={handleGroupInputKeyDown} autoComplete="off" innerRef={textInputGroupRef} placeholder={t('Enter groups')} role="combobox" isExpanded={isGroupSelectOpen} aria-controls="impersonate-groups-listbox" + aria-describedby="groups-help-text" /> {groupSearchFilter && ( @@ -323,6 +376,61 @@ export const ImpersonateUserModal: FC = ({ ); + // Build the dropdown options list + const renderSelectOptions = () => { + const options: JSX.Element[] = []; + + // Show "Select all" only when API groups are available and there are filtered results + if (filteredGroups.length > 0) { + options.push( + + {t('Select all')} + , + ); + + filteredGroups.forEach((group) => { + options.push( + + {group} + , + ); + }); + } + + // Show "Create" option for free-form entry when typed text is new + if (isCreatableGroup) { + options.push( + + {t('Create "{{groupName}}"', { groupName: groupSearchFilter.trim() })} + , + ); + } + + // Show hint when no options and no creatable text + if (options.length === 0) { + if (groupSearchFilter.trim()) { + // Text is typed but it's already selected + options.push( + + {t('Group already added')} + , + ); + } else { + options.push( + + {groupsAvailable ? t('No results found') : t('Type a group name and press Enter')} + , + ); + } + } + + return options; + }; + return ( @@ -365,12 +473,6 @@ export const ImpersonateUserModal: FC = ({ /> - {groupsLoadError && ( - - {groupsLoadError.message} - - )} - {impersonateKind === 'User' ? ( = ({ aria-label={t('Select groups to impersonate')} aria-describedby="groups-help-text" > - - {filteredGroups.length === 0 ? ( - {t('No results found')} - ) : ( - <> - - {t('Select all')} - - {filteredGroups.map((group) => ( - - {group} - - ))} - - )} - + {renderSelectOptions()} + {!groupsAvailable && ( + + + + {t('Type group names manually. Press Enter to add each group.')} + + + + )} + {selectedGroups.length > 0 && ( {visibleGroups.map((group) => ( diff --git a/frontend/public/locales/en/public.json b/frontend/public/locales/en/public.json index 550451ba1a0..c194121a7eb 100644 --- a/frontend/public/locales/en/public.json +++ b/frontend/public/locales/en/public.json @@ -443,6 +443,7 @@ "CrashLoopBackOff indicates that the application in the container is repeatedly failing to start.": "CrashLoopBackOff indicates that the application in the container is repeatedly failing to start.", "CRD versions": "CRD versions", "Create": "Create", + "Create \"{{groupName}}\"": "Create \"{{groupName}}\"", "Create {{formType}} secret": "Create {{formType}} secret", "Create {{label}}": "Create {{label}}", "Create {{objLabel}}": "Create {{objLabel}}", @@ -666,7 +667,6 @@ "Extra scopes": "Extra scopes", "Failed": "Failed", "Failed pods": "Failed pods", - "Failed to load groups": "Failed to load groups", "Failed to parse YAML sample": "Failed to parse YAML sample", "Failing": "Failing", "false": "false", @@ -727,6 +727,7 @@ "greater than pod_one": "greater than pod", "greater than pod_other": "greater than pods", "Group": "Group", + "Group already added": "Group already added", "Group by": "Group by", "Group details": "Group details", "Group interval": "Group interval", @@ -1641,6 +1642,8 @@ "Try the OpenShift Pipelines tutorial": "Try the OpenShift Pipelines tutorial", "Try the sample AI Chatbot Helm chart": "Try the sample AI Chatbot Helm chart", "Type": "Type", + "Type a group name and press Enter": "Type a group name and press Enter", + "Type group names manually. Press Enter to add each group.": "Type group names manually. Press Enter to add each group.", "Unable to load VolumeAttributesClass resources": "Unable to load VolumeAttributesClass resources", "Unable to resolve": "Unable to resolve", "Unable to Rollback": "Unable to Rollback",