Skip to content
代码片段 群组 项目
未验证 提交 d01abaae 编辑于 作者: Samantha Ming's avatar Samantha Ming 提交者: GitLab
浏览文件
No related branches found
No related tags found
无相关合并请求
......@@ -5,8 +5,10 @@ import { mapActions, mapState } from 'vuex';
import { __, s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import LicenseToken from './tokens/license_token.vue';
import ProjectToken from './tokens/project_token.vue';
import ComponentToken from './tokens/component_token.vue';
export default {
components: {
......@@ -15,6 +17,7 @@ export default {
GlLink,
GlSprintf,
},
mixins: [glFeatureFlagsMixin()],
inject: ['belowGroupLimit'],
data() {
return {
......@@ -42,6 +45,18 @@ export default {
token: ProjectToken,
operators: OPERATORS_IS,
},
...(this.glFeatures.groupLevelDependenciesFilteringByComponent
? [
{
type: 'component_ids',
title: __('Component'),
multiSelect: true,
unique: true,
token: ComponentToken,
operators: OPERATORS_IS,
},
]
: []),
];
},
},
......
<script>
import {
GlIcon,
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlLoadingIcon,
GlIntersperse,
} from '@gitlab/ui';
import { createAlert } from '~/alert';
import { s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
export default {
components: {
GlIcon,
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlLoadingIcon,
GlIntersperse,
},
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
active: {
type: Boolean,
required: true,
},
},
data() {
return {
searchTerm: '',
components: [],
selectedComponents: [],
isLoadingComponents: true,
};
},
computed: {
filteredComponents() {
if (!this.searchTerm) {
return this.components;
}
const nameIncludesSearchTerm = (component) =>
component.name.toLowerCase().includes(this.searchTerm);
const isSelected = (component) => this.selectedComponentNames.includes(component.name);
return this.components.filter(
(component) => nameIncludesSearchTerm(component) || isSelected(component),
);
},
selectedComponentNames() {
return this.selectedComponents.map(({ name }) => name);
},
selectedComponentIds() {
return this.selectedComponents.map(({ id }) => getIdFromGraphQLId(id));
},
tokenValue() {
return {
...this.value,
// when the token is active (dropdown is open), we set the value to null to prevent an UX issue
// in which only the last selected item is being displayed.
// more information: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2381
data: this.active ? null : this.selectedComponentNames,
};
},
},
created() {
this.fetchComponents();
},
methods: {
async fetchComponents() {
try {
this.isLoadingComponents = true;
// Note: This is just a placeholder. Adding the actual fetch logic will be addressed in a seperate issue:
// https://gitlab.com/gitlab-org/gitlab/-/issues/442407
this.components = await new Promise((resolve) => {
resolve([
{ id: 'gid://gitlab/Component/1', name: 'ComponentOne' },
{ id: 'gid://gitlab/Component/2', name: 'ComponentTwo' },
{ id: 'gid://gitlab/Component/3', name: 'ComponentThree' },
]);
});
} catch {
createAlert({
message: this.$options.i18n.fetchErrorMessage,
});
} finally {
this.isLoadingComponents = false;
}
},
isComponentSelected(component) {
return this.selectedComponents.some((c) => c.id === component.id);
},
toggleSelectedComponent(component) {
if (this.isComponentSelected(component)) {
this.selectedComponents = this.selectedComponents.filter((c) => c.id !== component.id);
} else {
this.selectedComponents.push(component);
}
},
handleInput(token) {
// the dropdown shows a list of component names but we need to emit the project ids for filtering
this.$emit('input', { ...token, data: this.selectedComponentIds });
},
setSearchTerm(token) {
// the data can be either a string or an array, in which case we don't want to perform the search
if (typeof token.data === 'string') {
this.searchTerm = token.data.toLowerCase();
}
},
},
i18n: {
fetchErrorMessage: s__(
'Dependencies|There was an error fetching the components for this group. Please try again later.',
),
},
};
</script>
<template>
<gl-filtered-search-token
:config="config"
v-bind="{ ...$props, ...$attrs }"
:multi-select-values="selectedComponentNames"
:value="tokenValue"
v-on="{ ...$listeners, input: handleInput }"
@select="toggleSelectedComponent"
@input="setSearchTerm"
>
<template #view>
<gl-intersperse data-testid="selected-components">
<span
v-for="selectedComponentName in selectedComponentNames"
:key="selectedComponentName"
>{{ selectedComponentName }}</span
>
</gl-intersperse>
</template>
<template #suggestions>
<gl-loading-icon v-if="isLoadingComponents" size="sm" />
<template v-else>
<gl-filtered-search-suggestion
v-for="component in filteredComponents"
:key="component.id"
:value="component"
>
<div class="gl-display-flex gl-align-items-center">
<gl-icon
v-if="config.multiSelect"
name="check"
class="gl-mr-3 gl-flex-shrink-0 gl-text-gray-700"
:class="{
'gl-visibility-hidden': !selectedComponentNames.includes(component.name),
}"
/>
{{ component.name }}
</div>
</gl-filtered-search-suggestion>
</template>
</template>
</gl-filtered-search-token>
</template>
......@@ -4,6 +4,10 @@ module Groups
class DependenciesController < Groups::ApplicationController
include GovernUsageGroupTracking
before_action only: :index do
push_frontend_feature_flag(:group_level_dependencies_filtering_by_component, group)
end
before_action :authorize_read_dependency_list!
before_action :validate_project_ids_limit!, only: :index
......
---
name: group_level_dependencies_filtering_by_component
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/442406
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/148257
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/454589
milestone: '16.11'
group: group::threat insights
type: wip
default_enabled: false
......@@ -3,6 +3,7 @@ import { GlFilteredSearch, GlPopover, GlSprintf } from '@gitlab/ui';
import GroupDependenciesFilteredSearch from 'ee/dependencies/components/filtered_search/group_dependencies_filtered_search.vue';
import LicenseToken from 'ee/dependencies/components/filtered_search/tokens/license_token.vue';
import ProjectToken from 'ee/dependencies/components/filtered_search/tokens/project_token.vue';
import ComponentToken from 'ee/dependencies/components/filtered_search/tokens/component_token.vue';
import createStore from 'ee/dependencies/store';
describe('GroupDependenciesFilteredSearch', () => {
......@@ -17,7 +18,10 @@ describe('GroupDependenciesFilteredSearch', () => {
const createComponent = (mountOptions = {}) => {
wrapper = shallowMount(GroupDependenciesFilteredSearch, {
store,
provide: { belowGroupLimit: true },
provide: {
belowGroupLimit: true,
glFeatures: { groupLevelDependenciesFilteringByComponent: true },
},
stubs: {
GlSprintf,
},
......@@ -47,9 +51,10 @@ describe('GroupDependenciesFilteredSearch', () => {
});
it.each`
tokenTitle | tokenConfig
${'License'} | ${{ title: 'License', type: 'licenses', multiSelect: true, token: LicenseToken }}
${'Project'} | ${{ title: 'Project', type: 'project_ids', multiSelect: true, token: ProjectToken }}
tokenTitle | tokenConfig
${'License'} | ${{ title: 'License', type: 'licenses', multiSelect: true, token: LicenseToken }}
${'Project'} | ${{ title: 'Project', type: 'project_ids', multiSelect: true, token: ProjectToken }}
${'Component'} | ${{ title: 'Component', type: 'component_ids', multiSelect: true, token: ComponentToken }}
`('contains a "$tokenTitle" search token', ({ tokenConfig }) => {
expect(findFilteredSearch().props('availableTokens')).toMatchObject(
expect.arrayContaining([
......@@ -102,4 +107,23 @@ describe('GroupDependenciesFilteredSearch', () => {
);
});
});
describe('with "groupLevelDependenciesFilteringByComponent" feature flag disabled', () => {
beforeEach(() => {
createComponent({
provide: {
belowGroupLimit: true,
glFeatures: { groupLevelDependenciesFilteringByComponent: false },
},
});
});
it('does not show the Component token', () => {
expect(findFilteredSearch().props('availableTokens')).not.toContainEqual(
expect.objectContaining({
title: 'Component',
}),
);
});
});
});
import {
GlFilteredSearchSuggestion,
GlFilteredSearchToken,
GlIcon,
GlIntersperse,
GlLoadingIcon,
} from '@gitlab/ui';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ComponentToken from 'ee/dependencies/components/filtered_search/tokens/component_token.vue';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/alert';
const TEST_COMPONENTS = [
{ id: 'gid://gitlab/Component/1', name: 'ComponentOne' },
{ id: 'gid://gitlab/Component/2', name: 'ComponentTwo' },
{ id: 'gid://gitlab/Component/3', name: 'ComponentThree' },
];
jest.mock('~/alert');
describe('ee/dependencies/components/filtered_search/tokens/component_token.vue', () => {
let wrapper;
const createComponent = ({ propsData = {} } = {}) => {
wrapper = shallowMountExtended(ComponentToken, {
propsData: {
config: {
multiSelect: true,
},
value: {},
active: false,
...propsData,
},
stubs: {
GlIntersperse,
},
});
};
const isLoadingSuggestions = () => wrapper.findComponent(GlLoadingIcon).exists();
const findSuggestions = () => wrapper.findAllComponents(GlFilteredSearchSuggestion);
const findFirstSearchSuggestionIcon = () => findSuggestions().at(0).findComponent(GlIcon);
const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken);
const selectComponent = (component) => {
findFilteredSearchToken().vm.$emit('select', component);
return nextTick();
};
const searchForComponent = (searchTerm = '') => {
findFilteredSearchToken().vm.$emit('input', { data: searchTerm });
return waitForPromises();
};
describe('when the component is initially rendered', () => {
it('shows a loading indicator while fetching the list of licenses', () => {
createComponent();
expect(isLoadingSuggestions()).toBe(true);
});
it.each([
{ active: true, expectedValue: null },
{ active: false, expectedValue: { data: [] } },
])(
'passes "$expectedValue" to the search-token when the dropdown is open: "$active"',
async ({ active, expectedValue }) => {
createComponent({
propsData: {
active,
value: { data: [] },
},
});
await waitForPromises();
expect(findFilteredSearchToken().props('value')).toEqual(
expect.objectContaining(expectedValue),
);
},
);
});
describe('when the list of components have been fetched successfully', () => {
beforeEach(async () => {
createComponent();
await waitForPromises();
});
it('does not show an error message', () => {
expect(createAlert).not.toHaveBeenCalled();
});
it('does not show a loading indicator', () => {
expect(isLoadingSuggestions()).toBe(false);
});
it('shows a list of project suggestions', () => {
const suggestions = findSuggestions();
expect(suggestions).toHaveLength(TEST_COMPONENTS.length);
expect(suggestions.at(0).text()).toBe(TEST_COMPONENTS[0].name);
expect(suggestions.at(1).text()).toBe(TEST_COMPONENTS[1].name);
expect(suggestions.at(2).text()).toBe(TEST_COMPONENTS[2].name);
});
describe('when a user selects projects to be filtered', () => {
it('displays a check-icon next to the selected project', async () => {
expect(findFirstSearchSuggestionIcon().classes()).toContain('gl-visibility-hidden');
await selectComponent(TEST_COMPONENTS[0]);
expect(findFirstSearchSuggestionIcon().classes()).not.toContain('gl-visibility-hidden');
});
it('shows a comma seperated list of selected projects', async () => {
await selectComponent(TEST_COMPONENTS[0]);
await selectComponent(TEST_COMPONENTS[1]);
expect(wrapper.findByTestId('selected-components').text()).toMatchInterpolatedText(
`${TEST_COMPONENTS[0].name}, ${TEST_COMPONENTS[1].name}`,
);
});
it(`emits the selected project's IDs without the GraphQL prefix`, async () => {
const tokenData = {
id: 'component_id',
type: 'component',
operator: '=',
};
const expectedIds = TEST_COMPONENTS.map((component) =>
Number(component.id.replace('gid://gitlab/Component/', '')),
);
await selectComponent(TEST_COMPONENTS[0]);
await selectComponent(TEST_COMPONENTS[1]);
await selectComponent(TEST_COMPONENTS[2]);
findFilteredSearchToken().vm.$emit('input', tokenData);
expect(wrapper.emitted('input')).toEqual([
[
{
...tokenData,
data: expectedIds,
},
],
]);
});
});
describe('when a user enters a search term', () => {
it('shows the filtered list of components', async () => {
await searchForComponent(TEST_COMPONENTS[0].name);
expect(findSuggestions()).toHaveLength(1);
expect(findSuggestions().at(0).text()).toBe(TEST_COMPONENTS[0].name);
});
it('shows the already selected components in the filtered list', async () => {
await selectComponent(TEST_COMPONENTS[0]);
await searchForComponent(TEST_COMPONENTS[1].name);
expect(findSuggestions()).toHaveLength(2);
expect(findSuggestions().at(0).text()).toBe(TEST_COMPONENTS[0].name);
expect(findSuggestions().at(1).text()).toBe(TEST_COMPONENTS[1].name);
});
});
});
});
......@@ -17222,6 +17222,9 @@ msgstr ""
msgid "Dependencies|There was a problem fetching vulnerabilities."
msgstr ""
 
msgid "Dependencies|There was an error fetching the components for this group. Please try again later."
msgstr ""
msgid "Dependencies|There was an error fetching the projects for this group. Please try again later."
msgstr ""
 
0% 加载中 .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册