diff --git a/app/assets/javascripts/ci/pipeline_details/manual_variables/empty_state.vue b/app/assets/javascripts/ci/pipeline_details/manual_variables/empty_state.vue new file mode 100644 index 0000000000000000000000000000000000000000..1ea007d5c2a33fef3689ca3884a5e0478bb44042 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/manual_variables/empty_state.vue @@ -0,0 +1,38 @@ +<script> +import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; +import EMPTY_VARIABLES_SVG from '@gitlab/svgs/dist/illustrations/empty-state/empty-variables-md.svg'; +import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +export default { + components: { + GlEmptyState, + GlSprintf, + GlLink, + }, + EMPTY_VARIABLES_SVG, + i18n: { + title: s__('ManualVariables|There are no manually-specified variables for this pipeline'), + description: s__( + 'ManualVariables|When you %{helpPageUrlStart}run a pipeline manually%{helpPageUrlEnd}, you can specify additional CI/CD variables to use in that pipeline run.', + ), + }, + runPipelineManuallyDocUrl: helpPagePath('ci/pipelines/index', { + anchor: 'run-a-pipeline-manually', + }), +}; +</script> + +<template> + <gl-empty-state :svg-path="$options.EMPTY_VARIABLES_SVG" :title="$options.i18n.title"> + <template #description> + <gl-sprintf :message="$options.i18n.description"> + <template #helpPageUrl="{ content }"> + <gl-link :href="$options.runPipelineManuallyDocUrl" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/manual_variables/graphql/queries/get_manual_variables.query.graphql b/app/assets/javascripts/ci/pipeline_details/manual_variables/graphql/queries/get_manual_variables.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..200a75c634fd6a812a1febbcd701ae18907dee55 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/manual_variables/graphql/queries/get_manual_variables.query.graphql @@ -0,0 +1,17 @@ +query getManualVariables($projectPath: ID!, $iid: ID!) { + project(fullPath: $projectPath) { + __typename + id + pipeline(iid: $iid) { + id + manualVariables { + __typename + nodes { + id + key + value + } + } + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_details/manual_variables/manual_variables.vue b/app/assets/javascripts/ci/pipeline_details/manual_variables/manual_variables.vue new file mode 100644 index 0000000000000000000000000000000000000000..c2288b62eb902ed3361ee1f61f5c4aed1a7269eb --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/manual_variables/manual_variables.vue @@ -0,0 +1,51 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import EmptyState from './empty_state.vue'; +import VariableTable from './variable_table.vue'; +import getManualVariablesQuery from './graphql/queries/get_manual_variables.query.graphql'; + +export default { + name: 'ManualVariablesApp', + components: { + EmptyState, + GlLoadingIcon, + VariableTable, + }, + inject: ['manualVariablesCount', 'projectPath', 'pipelineIid'], + apollo: { + variables: { + query: getManualVariablesQuery, + skip() { + return !this.hasManualVariables; + }, + variables() { + return { + projectPath: this.projectPath, + iid: this.pipelineIid, + }; + }, + update({ project }) { + return project?.pipeline?.manualVariables?.nodes || []; + }, + }, + }, + computed: { + loading() { + return this.$apollo.queries.variables.loading; + }, + hasManualVariables() { + return Boolean(this.manualVariablesCount > 0); + }, + }, +}; +</script> + +<template> + <div> + <div v-if="hasManualVariables" class="manual-variables-table"> + <gl-loading-icon v-if="loading" /> + <variable-table v-else :variables="variables" /> + </div> + <empty-state v-else /> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/manual_variables/variable_table.vue b/app/assets/javascripts/ci/pipeline_details/manual_variables/variable_table.vue new file mode 100644 index 0000000000000000000000000000000000000000..3eceb12554e99b3a1494518d8fee68344661fd5b --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/manual_variables/variable_table.vue @@ -0,0 +1,90 @@ +<script> +import { GlButton, GlPagination, GlTableLite } from '@gitlab/ui'; +import { __ } from '~/locale'; + +// The number of items per page is based on the design mockup. +// Please refer to https://gitlab.com/gitlab-org/gitlab/-/issues/323097/designs/TabVariables.png +const VARIABLES_PER_PAGE = 15; + +export default { + components: { + GlButton, + GlPagination, + GlTableLite, + }, + inject: ['manualVariablesCount', 'canReadVariables'], + props: { + variables: { + type: Array, + required: true, + }, + }, + data() { + return { + revealed: false, + currentPage: 1, + hasPermission: true, + }; + }, + computed: { + buttonText() { + return this.revealed ? __('Hide values') : __('Reveal values'); + }, + showPager() { + return this.manualVariablesCount > VARIABLES_PER_PAGE; + }, + items() { + const start = (this.currentPage - 1) * VARIABLES_PER_PAGE; + const end = start + VARIABLES_PER_PAGE; + return this.variables.slice(start, end); + }, + }, + methods: { + toggleRevealed() { + this.revealed = !this.revealed; + }, + }, + TABLE_FIELDS: [ + { + key: 'key', + label: __('Key'), + }, + { + key: 'value', + label: __('Value'), + }, + ], + VARIABLES_PER_PAGE, +}; +</script> + +<template> + <!-- This negative margin top is a hack for the purpose to eliminate default padding of tab container --> + <!-- For context refer to: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/159206#note_1999122459 --> + <div class="-gl-mt-3"> + <div v-if="canReadVariables" class="gl-p-3 gl-bg-gray-10"> + <gl-button :aria-label="buttonText" @click="toggleRevealed">{{ buttonText }}</gl-button> + </div> + <gl-table-lite :fields="$options.TABLE_FIELDS" :items="items"> + <template #cell(key)="{ value }"> + <span class="gl-text-secondary"> + {{ value }} + </span> + </template> + <template #cell(value)="{ value }"> + <div class="gl-text-secondary" data-testid="manual-variable-value"> + <span v-if="revealed">{{ value }}</span> + <span v-else>****</span> + </div> + </template> + </gl-table-lite> + <gl-pagination + v-if="showPager" + v-model="currentPage" + class="gl-mt-6" + :per-page="$options.VARIABLES_PER_PAGE" + :total-items="manualVariablesCount" + align="center" + /> + </div> +</template> diff --git a/locale/gitlab.pot b/locale/gitlab.pot index e70fe78be83d3243d3a1da9a666a860b67758b20..a50f67a75a6dc19bd8351aed0dcabcede3f5662c 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -31675,6 +31675,12 @@ msgstr "" msgid "ManualOrdering|Couldn't save the order of the issues" msgstr "" +msgid "ManualVariables|There are no manually-specified variables for this pipeline" +msgstr "" + +msgid "ManualVariables|When you %{helpPageUrlStart}run a pipeline manually%{helpPageUrlEnd}, you can specify additional CI/CD variables to use in that pipeline run." +msgstr "" + msgid "Manually link this issue by adding it to the linked issue section of the %{linkStart}originating vulnerability%{linkEnd}." msgstr "" diff --git a/spec/frontend/ci/pipeline_details/manual_variables/empty_state_spec.js b/spec/frontend/ci/pipeline_details/manual_variables/empty_state_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..b82f73f36f31f238c5b2fd69663e9e1b32db200a --- /dev/null +++ b/spec/frontend/ci/pipeline_details/manual_variables/empty_state_spec.js @@ -0,0 +1,23 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlEmptyState } from '@gitlab/ui'; +import EmptyState from '~/ci/pipeline_details/manual_variables/empty_state.vue'; + +describe('ManualVariablesEmptyState', () => { + describe('when component is created', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(EmptyState); + }; + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + + it('should render empty state with message', () => { + createComponent(); + + expect(findEmptyState().props()).toMatchObject({ + svgPath: EmptyState.EMPTY_VARIABLES_SVG, + title: 'There are no manually-specified variables for this pipeline', + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/manual_variables/manual_variables_spec.js b/spec/frontend/ci/pipeline_details/manual_variables/manual_variables_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..f4d9f13c1d1f7bd7cacbf3a0645bd8df6d874555 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/manual_variables/manual_variables_spec.js @@ -0,0 +1,67 @@ +import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import { GlLoadingIcon } from '@gitlab/ui'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import ManualVariablesApp from '~/ci/pipeline_details/manual_variables/manual_variables.vue'; +import EmptyState from '~/ci/pipeline_details/manual_variables/empty_state.vue'; +import VariableTable from '~/ci/pipeline_details/manual_variables/variable_table.vue'; +import GetManualVariablesQuery from '~/ci/pipeline_details/manual_variables/graphql/queries/get_manual_variables.query.graphql'; +import { generateVariablePairs, mockManualVariableConnection } from './mock_data'; + +Vue.use(VueApollo); + +describe('ManualVariableApp', () => { + let wrapper; + const mockResolver = jest.fn(); + const createMockApolloProvider = (resolver) => { + const requestHandlers = [[GetManualVariablesQuery, resolver]]; + + return createMockApollo(requestHandlers); + }; + + const createComponent = (variables = []) => { + mockResolver.mockResolvedValue(mockManualVariableConnection(variables)); + wrapper = shallowMount(ManualVariablesApp, { + provide: { + manualVariablesCount: variables.length, + projectPath: 'root/ci-project', + pipelineIid: '1', + }, + apolloProvider: createMockApolloProvider(mockResolver), + }); + }; + + const findEmptyState = () => wrapper.findComponent(EmptyState); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findVariableTable = () => wrapper.findComponent(VariableTable); + + afterEach(() => { + mockResolver.mockClear(); + }); + + describe('when component is created', () => { + it('renders empty state when no variables were found', () => { + createComponent(); + + expect(findEmptyState().exists()).toBe(true); + }); + + it('renders loading state when variables were found', () => { + createComponent(generateVariablePairs(1)); + + expect(findEmptyState().exists()).toBe(false); + expect(findLoadingIcon().exists()).toBe(true); + expect(findVariableTable().exists()).toBe(false); + }); + + it('renders variable table when variables were retrieved', async () => { + createComponent(generateVariablePairs(1)); + await waitForPromises(); + + expect(findLoadingIcon().exists()).toBe(false); + expect(findVariableTable().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/manual_variables/mock_data.js b/spec/frontend/ci/pipeline_details/manual_variables/mock_data.js new file mode 100644 index 0000000000000000000000000000000000000000..37d42598255cced11a17c3cec91c0219b19c9a1f --- /dev/null +++ b/spec/frontend/ci/pipeline_details/manual_variables/mock_data.js @@ -0,0 +1,26 @@ +export const generateVariablePairs = (count) => { + return Array.from({ length: count }).map((_, index) => ({ + key: `key_${index}`, + value: `value_${index}`, + })); +}; + +export const mockManualVariableConnection = (variables = []) => ({ + data: { + project: { + __typename: 'Project', + id: 'root/ci-project/1', + pipeline: { + id: '1', + manualVariables: { + __typename: 'CiManualVariableConnection', + nodes: variables.map((variable) => ({ + ...variable, + id: variable.key, + })), + }, + __typename: 'Pipeline', + }, + }, + }, +}); diff --git a/spec/frontend/ci/pipeline_details/manual_variables/variable_table_spec.js b/spec/frontend/ci/pipeline_details/manual_variables/variable_table_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..276be0f28f05de944b1778c536fda5c173bf717f --- /dev/null +++ b/spec/frontend/ci/pipeline_details/manual_variables/variable_table_spec.js @@ -0,0 +1,113 @@ +import { nextTick } from 'vue'; +import { GlPagination, GlButton } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; + +import VariableTable from '~/ci/pipeline_details/manual_variables/variable_table.vue'; +import { generateVariablePairs } from './mock_data'; + +const defaultCanReadVariables = true; +const defaultManualVariablesCount = 0; + +describe('ManualVariableTable', () => { + let wrapper; + + const createComponent = (provides = {}, variables = []) => { + wrapper = mountExtended(VariableTable, { + provide: { + manualVariablesCount: defaultManualVariablesCount, + canReadVariables: defaultCanReadVariables, + ...provides, + }, + propsData: { + variables, + }, + }); + }; + + const findButton = () => wrapper.findComponent(GlButton); + const findPaginator = () => wrapper.findComponent(GlPagination); + const findValues = () => wrapper.findAllByTestId('manual-variable-value'); + + describe('when component is created', () => { + describe('reveal/hide button', () => { + it('should render the button when has permissions', () => { + createComponent(); + + expect(findButton().exists()).toBe(true); + }); + + it('should not render the button when does not have permissions', () => { + createComponent({ + canReadVariables: false, + }); + + expect(findButton().exists()).toBe(false); + }); + }); + + describe('paginator', () => { + it('should not render paginator without any data', () => { + createComponent(); + + expect(findPaginator().exists()).toBe(false); + }); + + it('should not render paginator with data less or equal to 15', () => { + const mockData = generateVariablePairs(15); + createComponent( + { + manualVariablesCount: mockData.length, + }, + mockData, + ); + + expect(findPaginator().exists()).toBe(false); + }); + + it('should render paginator when data is greater than 15', () => { + const mockData = generateVariablePairs(16); + + createComponent( + { + manualVariablesCount: mockData.length, + }, + mockData, + ); + + expect(findPaginator().exists()).toBe(true); + }); + }); + }); + + describe('when click on the reveal/hide button', () => { + it('should toggle button text', async () => { + createComponent(); + const button = findButton(); + + expect(button.text()).toBe('Reveal values'); + button.vm.$emit('click'); + await nextTick(); + expect(button.text()).toBe('Hide values'); + }); + + it('should reveal the values when click on the button', async () => { + const mockData = generateVariablePairs(15); + + createComponent( + { + manualVariablesCount: mockData.length, + }, + mockData, + ); + + const values = findValues(); + expect(values).toHaveLength(mockData.length); + + expect(values.wrappers.every((w) => w.text() === '****')).toBe(true); + + await findButton().trigger('click'); + + expect(values.wrappers.map((w) => w.text())).toStrictEqual(mockData.map((d) => d.value)); + }); + }); +});