Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions shell/assets/translations/en-us.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2345,6 +2345,7 @@ cluster:
placeholder: Search for a member to provide cluster access
searchPlaceholder: Start typing to search
noResults: No results found
minCharacters: Type at least {count} characters to search
privateRegistry:
header: Registry for Rancher System Container Images
label: Enable cluster scoped container registry for Rancher system container images
Expand Down
30 changes: 24 additions & 6 deletions shell/components/auth/SelectPrincipal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,13 @@ export default {

data() {
return {
principals: null,
searchStr: '',
options: [],
newValue: '',
tooltipContent: null,
principals: null,
searchStr: '',
options: [],
newValue: '',
tooltipContent: null,
hasSearchTooShort: false,
minSearchLength: 2,
};
},

Expand Down Expand Up @@ -133,9 +135,20 @@ export default {
this.searchStr = str;

if ( str ) {
// Backend requires minimum 2 characters for search
if (str.length < this.minSearchLength) {
this.hasSearchTooShort = true;
this.options = [];
loading(false);

return;
}

this.hasSearchTooShort = false;
loading(true);
this.debouncedSearch(str, loading);
} else {
this.hasSearchTooShort = false;
this.search(null, loading);
}
},
Expand Down Expand Up @@ -196,7 +209,12 @@ export default {
@on-close="setTooltipContent()"
>
<template v-slot:no-options="{ searching }">
<template v-if="searching">
<template v-if="hasSearchTooShort">
<span class="search-slot">
{{ t('cluster.memberRoles.addClusterMember.minCharacters', { count: minSearchLength }) }}
</span>
</template>
<template v-else-if="searching">
<span class="search-slot">
{{ t('cluster.memberRoles.addClusterMember.noResults') }}
</span>
Expand Down
119 changes: 119 additions & 0 deletions shell/components/auth/__tests__/SelectPrincipal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { shallowMount, type VueWrapper } from '@vue/test-utils';
import SelectPrincipal from '@shell/components/auth/SelectPrincipal.vue';

describe('component: SelectPrincipal', () => {
const mockStore = { dispatch: jest.fn().mockResolvedValue([]) };

const defaultMountOptions = {
global: {
mocks: {
$fetchState: { pending: false },
$store: mockStore,
t: (key: string, opts?: any) => opts?.count ? `${ key } ${ opts.count }` : key,
},
stubs: {
LabeledSelect: {
template: '<div class="labeled-select-stub"><slot name="no-options" :searching="searching" /></div>',
props: ['options', 'searchable', 'filterable'],
data() {
return { searching: false };
}
},
Principal: true,
},
},
};

beforeEach(() => {
jest.clearAllMocks();
mockStore.dispatch.mockResolvedValue([]);
});

describe('onSearch', () => {
it('should set hasSearchTooShort to true when search string is less than minSearchLength', async() => {
const wrapper: VueWrapper<any> = shallowMount(SelectPrincipal, defaultMountOptions);

// Set principals to an empty array to avoid null errors
wrapper.vm.principals = [];
await wrapper.vm.$nextTick();

const loadingFn = jest.fn();

wrapper.vm.onSearch('a', loadingFn);

expect(wrapper.vm.hasSearchTooShort).toBe(true);
expect(wrapper.vm.options).toStrictEqual([]);
expect(loadingFn).toHaveBeenCalledWith(false);
});

it('should set hasSearchTooShort to false when search string meets minSearchLength', async() => {
const wrapper: VueWrapper<any> = shallowMount(SelectPrincipal, defaultMountOptions);

wrapper.vm.principals = [];
await wrapper.vm.$nextTick();

const loadingFn = jest.fn();

wrapper.vm.onSearch('ab', loadingFn);

expect(wrapper.vm.hasSearchTooShort).toBe(false);
expect(loadingFn).toHaveBeenCalledWith(true);
});

it('should set hasSearchTooShort to false when search string is empty', async() => {
const wrapper: VueWrapper<any> = shallowMount(SelectPrincipal, defaultMountOptions);

wrapper.vm.principals = [];
await wrapper.vm.$nextTick();

// First set hasSearchTooShort to true
wrapper.vm.hasSearchTooShort = true;

const loadingFn = jest.fn();

wrapper.vm.onSearch('', loadingFn);

expect(wrapper.vm.hasSearchTooShort).toBe(false);
});

it('should not call debouncedSearch when search string is too short', async() => {
const wrapper: VueWrapper<any> = shallowMount(SelectPrincipal, defaultMountOptions);

wrapper.vm.principals = [];
await wrapper.vm.$nextTick();

// Spy on the debounced search
const debouncedSearchSpy = jest.spyOn(wrapper.vm, 'debouncedSearch');
const loadingFn = jest.fn();

wrapper.vm.onSearch('x', loadingFn);

expect(debouncedSearchSpy).not.toHaveBeenCalled();
});

it('should call debouncedSearch when search string meets minimum length', async() => {
const wrapper: VueWrapper<any> = shallowMount(SelectPrincipal, defaultMountOptions);

wrapper.vm.principals = [];
await wrapper.vm.$nextTick();

const debouncedSearchSpy = jest.spyOn(wrapper.vm, 'debouncedSearch');
const loadingFn = jest.fn();

wrapper.vm.onSearch('xy', loadingFn);

expect(debouncedSearchSpy).toHaveBeenCalledWith('xy', loadingFn);
});
});

describe('minSearchLength', () => {
it('should have a default minSearchLength of 2', async() => {
const wrapper: VueWrapper<any> = shallowMount(SelectPrincipal, defaultMountOptions);

wrapper.vm.principals = [];
await wrapper.vm.$nextTick();

expect(wrapper.vm.minSearchLength).toBe(2);
});
});
});
Loading