Commit 696855d8 authored by Jacques Erasmus's avatar Jacques Erasmus

Merge branch 'terraform-state-errors-graphql' into 'master'

Display Terraform action errors to user

See merge request gitlab-org/gitlab!51397
parents 32101e1d 747eaf20
<script> <script>
import { GlBadge, GlIcon, GlLink, GlSprintf, GlTable, GlTooltip } from '@gitlab/ui'; import { GlAlert, GlBadge, GlIcon, GlLink, GlSprintf, GlTable, GlTooltip } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
...@@ -10,6 +10,7 @@ import timeagoMixin from '~/vue_shared/mixins/timeago'; ...@@ -10,6 +10,7 @@ import timeagoMixin from '~/vue_shared/mixins/timeago';
export default { export default {
components: { components: {
CiBadge, CiBadge,
GlAlert,
GlBadge, GlBadge,
GlIcon, GlIcon,
GlLink, GlLink,
...@@ -105,6 +106,7 @@ export default { ...@@ -105,6 +106,7 @@ export default {
:items="states" :items="states"
:fields="fields" :fields="fields"
data-testid="terraform-states-table" data-testid="terraform-states-table"
details-td-class="gl-p-0!"
fixed fixed
stacked="md" stacked="md"
> >
...@@ -189,5 +191,21 @@ export default { ...@@ -189,5 +191,21 @@ export default {
<template v-if="terraformAdmin" #cell(actions)="{ item }"> <template v-if="terraformAdmin" #cell(actions)="{ item }">
<state-actions :state="item" /> <state-actions :state="item" />
</template> </template>
<template #row-details="row">
<gl-alert
data-testid="terraform-states-table-error"
variant="danger"
@dismiss="row.toggleDetails"
>
<span
v-for="errorMessage in row.item.errorMessages"
:key="errorMessage"
class="gl-display-flex gl-justify-content-start"
>
{{ errorMessage }}
</span>
</gl-alert>
</template>
</gl-table> </gl-table>
</template> </template>
...@@ -10,6 +10,7 @@ import { ...@@ -10,6 +10,7 @@ import {
GlSprintf, GlSprintf,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import addDataToState from '../graphql/mutations/add_data_to_state.mutation.graphql';
import lockState from '../graphql/mutations/lock_state.mutation.graphql'; import lockState from '../graphql/mutations/lock_state.mutation.graphql';
import unlockState from '../graphql/mutations/unlock_state.mutation.graphql'; import unlockState from '../graphql/mutations/unlock_state.mutation.graphql';
import removeState from '../graphql/mutations/remove_state.mutation.graphql'; import removeState from '../graphql/mutations/remove_state.mutation.graphql';
...@@ -33,13 +34,13 @@ export default { ...@@ -33,13 +34,13 @@ export default {
}, },
data() { data() {
return { return {
loading: false,
showRemoveModal: false, showRemoveModal: false,
removeConfirmText: '', removeConfirmText: '',
}; };
}, },
i18n: { i18n: {
downloadJSON: s__('Terraform|Download JSON'), downloadJSON: s__('Terraform|Download JSON'),
errorUpdate: s__('Terraform|An error occurred while changing the state file'),
lock: s__('Terraform|Lock'), lock: s__('Terraform|Lock'),
modalBody: s__( modalBody: s__(
'Terraform|You are about to remove the State file %{name}. This will permanently delete all the State versions and history. The infrastructure provisioned previously will remain intact, only the state file with all its versions are to be removed. This action is non-revertible.', 'Terraform|You are about to remove the State file %{name}. This will permanently delete all the State versions and history. The infrastructure provisioned previously will remain intact, only the state file with all its versions are to be removed. This action is non-revertible.',
...@@ -76,19 +77,37 @@ export default { ...@@ -76,19 +77,37 @@ export default {
this.removeConfirmText = ''; this.removeConfirmText = '';
}, },
lock() { lock() {
this.stateMutation(lockState); this.stateActionMutation(lockState);
}, },
unlock() { unlock() {
this.stateMutation(unlockState); this.stateActionMutation(unlockState);
},
updateStateCache(newData) {
this.$apollo.mutate({
mutation: addDataToState,
variables: {
terraformState: {
...this.state,
...newData,
},
},
});
}, },
remove() { remove() {
if (!this.disableModalSubmit) { if (!this.disableModalSubmit) {
this.hideModal(); this.hideModal();
this.stateMutation(removeState); this.stateActionMutation(removeState);
} }
}, },
stateMutation(mutation) { stateActionMutation(mutation) {
this.loading = true; let errorMessages = [];
this.updateStateCache({
_showDetails: false,
errorMessages,
loadingActions: true,
});
this.$apollo this.$apollo
.mutate({ .mutate({
mutation, mutation,
...@@ -99,9 +118,22 @@ export default { ...@@ -99,9 +118,22 @@ export default {
awaitRefetchQueries: true, awaitRefetchQueries: true,
notifyOnNetworkStatusChange: true, notifyOnNetworkStatusChange: true,
}) })
.catch(() => {}) .then(({ data }) => {
errorMessages =
data?.terraformStateDelete?.errors ||
data?.terraformStateLock?.errors ||
data?.terraformStateUnlock?.errors ||
[];
})
.catch(() => {
errorMessages = [this.$options.i18n.errorUpdate];
})
.finally(() => { .finally(() => {
this.loading = false; this.updateStateCache({
_showDetails: Boolean(errorMessages.length),
errorMessages,
loadingActions: false,
});
}); });
}, },
}, },
...@@ -114,7 +146,7 @@ export default { ...@@ -114,7 +146,7 @@ export default {
icon="ellipsis_v" icon="ellipsis_v"
right right
:data-testid="`terraform-state-actions-${state.name}`" :data-testid="`terraform-state-actions-${state.name}`"
:disabled="loading" :disabled="state.loadingActions"
toggle-class="gl-px-3! gl-shadow-none!" toggle-class="gl-px-3! gl-shadow-none!"
> >
<template #button-content> <template #button-content>
......
...@@ -2,6 +2,10 @@ ...@@ -2,6 +2,10 @@
#import "./state_version.fragment.graphql" #import "./state_version.fragment.graphql"
fragment State on TerraformState { fragment State on TerraformState {
_showDetails @client
errorMessages @client
loadingActions @client
id id
name name
lockedAt lockedAt
......
mutation addDataToTerraformState($terraformState: State!) {
addDataToTerraformState(terraformState: $terraformState) @client
}
import TerraformState from './fragments/state.fragment.graphql';
export default {
TerraformState: {
_showDetails: (state) => {
// eslint-disable-next-line no-underscore-dangle
return state._showDetails || false;
},
errorMessages: (state) => {
return state.errorMessages || [];
},
loadingActions: (state) => {
return state.loadingActions || false;
},
},
Mutation: {
addDataToTerraformState: (_, { terraformState }, { client }) => {
const fragmentData = {
id: terraformState.id,
fragment: TerraformState,
// eslint-disable-next-line @gitlab/require-i18n-strings
fragmentName: 'State',
};
const previousTerraformState = client.readFragment(fragmentData);
if (previousTerraformState) {
client.writeFragment({
...fragmentData,
data: {
...previousTerraformState,
// eslint-disable-next-line no-underscore-dangle
_showDetails: terraformState._showDetails,
errorMessages: terraformState.errorMessages,
loadingActions: terraformState.loadingActions,
},
});
}
},
},
};
import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import TerraformList from './components/terraform_list.vue'; import TerraformList from './components/terraform_list.vue';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import resolvers from './graphql/resolvers';
Vue.use(VueApollo); Vue.use(VueApollo);
...@@ -12,7 +14,13 @@ export default () => { ...@@ -12,7 +14,13 @@ export default () => {
return null; return null;
} }
const defaultClient = createDefaultClient(); const defaultClient = createDefaultClient(resolvers, {
cacheConfig: {
dataIdFromObject: (object) => {
return object.id || defaultDataIdFromObject(object);
},
},
});
const { emptyStateImage, projectPath } = el.dataset; const { emptyStateImage, projectPath } = el.dataset;
......
---
title: Display Terraform list errors to user
merge_request: 51397
author:
type: changed
...@@ -27881,6 +27881,9 @@ msgstr "" ...@@ -27881,6 +27881,9 @@ msgstr ""
msgid "Terraform|Actions" msgid "Terraform|Actions"
msgstr "" msgstr ""
msgid "Terraform|An error occurred while changing the state file"
msgstr ""
msgid "Terraform|An error occurred while loading your Terraform States" msgid "Terraform|An error occurred while loading your Terraform States"
msgstr "" msgstr ""
......
import { GlDropdown, GlModal, GlSprintf } from '@gitlab/ui'; import { GlDropdown, GlModal, GlSprintf } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import StateActions from '~/terraform/components/states_table_actions.vue'; import StateActions from '~/terraform/components/states_table_actions.vue';
import lockStateMutation from '~/terraform/graphql/mutations/lock_state.mutation.graphql'; import lockStateMutation from '~/terraform/graphql/mutations/lock_state.mutation.graphql';
...@@ -14,6 +15,7 @@ describe('StatesTableActions', () => { ...@@ -14,6 +15,7 @@ describe('StatesTableActions', () => {
let lockResponse; let lockResponse;
let removeResponse; let removeResponse;
let unlockResponse; let unlockResponse;
let updateStateResponse;
let wrapper; let wrapper;
const defaultProps = { const defaultProps = {
...@@ -26,7 +28,9 @@ describe('StatesTableActions', () => { ...@@ -26,7 +28,9 @@ describe('StatesTableActions', () => {
}; };
const createMockApolloProvider = () => { const createMockApolloProvider = () => {
lockResponse = jest.fn().mockResolvedValue({ data: { terraformStateLock: { errors: [] } } }); lockResponse = jest
.fn()
.mockResolvedValue({ data: { terraformStateLock: { errors: ['There was an error'] } } });
removeResponse = jest removeResponse = jest
.fn() .fn()
...@@ -36,11 +40,20 @@ describe('StatesTableActions', () => { ...@@ -36,11 +40,20 @@ describe('StatesTableActions', () => {
.fn() .fn()
.mockResolvedValue({ data: { terraformStateUnlock: { errors: [] } } }); .mockResolvedValue({ data: { terraformStateUnlock: { errors: [] } } });
return createMockApollo([ updateStateResponse = jest.fn().mockResolvedValue({});
[lockStateMutation, lockResponse],
[removeStateMutation, removeResponse], return createMockApollo(
[unlockStateMutation, unlockResponse], [
]); [lockStateMutation, lockResponse],
[removeStateMutation, removeResponse],
[unlockStateMutation, unlockResponse],
],
{
Mutation: {
addDataToTerraformState: updateStateResponse,
},
},
);
}; };
const createComponent = (propsData = defaultProps) => { const createComponent = (propsData = defaultProps) => {
...@@ -56,6 +69,7 @@ describe('StatesTableActions', () => { ...@@ -56,6 +69,7 @@ describe('StatesTableActions', () => {
return wrapper.vm.$nextTick(); return wrapper.vm.$nextTick();
}; };
const findActionsDropdown = () => wrapper.find(GlDropdown);
const findLockBtn = () => wrapper.find('[data-testid="terraform-state-lock"]'); const findLockBtn = () => wrapper.find('[data-testid="terraform-state-lock"]');
const findUnlockBtn = () => wrapper.find('[data-testid="terraform-state-unlock"]'); const findUnlockBtn = () => wrapper.find('[data-testid="terraform-state-unlock"]');
const findDownloadBtn = () => wrapper.find('[data-testid="terraform-state-download"]'); const findDownloadBtn = () => wrapper.find('[data-testid="terraform-state-download"]');
...@@ -70,9 +84,25 @@ describe('StatesTableActions', () => { ...@@ -70,9 +84,25 @@ describe('StatesTableActions', () => {
lockResponse = null; lockResponse = null;
removeResponse = null; removeResponse = null;
unlockResponse = null; unlockResponse = null;
updateStateResponse = null;
wrapper.destroy(); wrapper.destroy();
}); });
describe('when the state is loading', () => {
beforeEach(() => {
return createComponent({
state: {
...defaultProps.state,
loadingActions: true,
},
});
});
it('disables the actions dropdown', () => {
expect(findActionsDropdown().props('disabled')).toBe(true);
});
});
describe('download button', () => { describe('download button', () => {
it('displays a download button', () => { it('displays a download button', () => {
expect(findDownloadBtn().text()).toBe('Download JSON'); expect(findDownloadBtn().text()).toBe('Download JSON');
...@@ -104,7 +134,8 @@ describe('StatesTableActions', () => { ...@@ -104,7 +134,8 @@ describe('StatesTableActions', () => {
describe('when clicking the unlock button', () => { describe('when clicking the unlock button', () => {
beforeEach(() => { beforeEach(() => {
findUnlockBtn().vm.$emit('click'); findUnlockBtn().vm.$emit('click');
return wrapper.vm.$nextTick();
return waitForPromises();
}); });
it('calls the unlock mutation', () => { it('calls the unlock mutation', () => {
...@@ -137,7 +168,8 @@ describe('StatesTableActions', () => { ...@@ -137,7 +168,8 @@ describe('StatesTableActions', () => {
describe('when clicking the lock button', () => { describe('when clicking the lock button', () => {
beforeEach(() => { beforeEach(() => {
findLockBtn().vm.$emit('click'); findLockBtn().vm.$emit('click');
return wrapper.vm.$nextTick();
return waitForPromises();
}); });
it('calls the lock mutation', () => { it('calls the lock mutation', () => {
...@@ -145,6 +177,42 @@ describe('StatesTableActions', () => { ...@@ -145,6 +177,42 @@ describe('StatesTableActions', () => {
stateID: unlockedProps.state.id, stateID: unlockedProps.state.id,
}); });
}); });
it('calls mutations to set loading and errors', () => {
// loading update
expect(updateStateResponse).toHaveBeenNthCalledWith(
1,
{},
{
terraformState: {
...unlockedProps.state,
_showDetails: false,
errorMessages: [],
loadingActions: true,
},
},
// Apollo fields
expect.any(Object),
expect.any(Object),
);
// final update
expect(updateStateResponse).toHaveBeenNthCalledWith(
2,
{},
{
terraformState: {
...unlockedProps.state,
_showDetails: true,
errorMessages: ['There was an error'],
loadingActions: false,
},
},
// Apollo fields
expect.any(Object),
expect.any(Object),
);
});
}); });
}); });
...@@ -156,7 +224,8 @@ describe('StatesTableActions', () => { ...@@ -156,7 +224,8 @@ describe('StatesTableActions', () => {
describe('when clicking the remove button', () => { describe('when clicking the remove button', () => {
beforeEach(() => { beforeEach(() => {
findRemoveBtn().vm.$emit('click'); findRemoveBtn().vm.$emit('click');
return wrapper.vm.$nextTick();
return waitForPromises();
}); });
it('displays a remove modal', () => { it('displays a remove modal', () => {
......
...@@ -11,6 +11,8 @@ describe('StatesTable', () => { ...@@ -11,6 +11,8 @@ describe('StatesTable', () => {
const defaultProps = { const defaultProps = {
states: [ states: [
{ {
_showDetails: true,
errorMessages: ['State 1 has errored'],
name: 'state-1', name: 'state-1',
lockedAt: '2020-10-13T00:00:00Z', lockedAt: '2020-10-13T00:00:00Z',
lockedByUser: { lockedByUser: {
...@@ -20,6 +22,8 @@ describe('StatesTable', () => { ...@@ -20,6 +22,8 @@ describe('StatesTable', () => {
latestVersion: null, latestVersion: null,
}, },
{ {
_showDetails: false,
errorMessages: [],
name: 'state-2', name: 'state-2',
lockedAt: null, lockedAt: null,
lockedByUser: null, lockedByUser: null,
...@@ -27,6 +31,8 @@ describe('StatesTable', () => { ...@@ -27,6 +31,8 @@ describe('StatesTable', () => {
latestVersion: null, latestVersion: null,
}, },
{ {
_showDetails: false,
errorMessages: [],
name: 'state-3', name: 'state-3',
lockedAt: '2020-10-10T00:00:00Z', lockedAt: '2020-10-10T00:00:00Z',
lockedByUser: { lockedByUser: {
...@@ -54,6 +60,8 @@ describe('StatesTable', () => { ...@@ -54,6 +60,8 @@ describe('StatesTable', () => {
}, },
}, },
{ {
_showDetails: true,
errorMessages: ['State 4 has errored'],
name: 'state-4', name: 'state-4',
lockedAt: '2020-10-10T00:00:00Z', lockedAt: '2020-10-10T00:00:00Z',
lockedByUser: null, lockedByUser: null,
...@@ -154,6 +162,17 @@ describe('StatesTable', () => { ...@@ -154,6 +162,17 @@ describe('StatesTable', () => {
expect(findActions().length).toEqual(0); expect(findActions().length).toEqual(0);
}); });
it.each`
errorMessage | lineNumber
${defaultProps.states[0].errorMessages[0]} | ${0}
${defaultProps.states[3].errorMessages[0]} | ${1}
`('displays table error message "$errorMessage"', ({ errorMessage, lineNumber }) => {
const states = wrapper.findAll('[data-testid="terraform-states-table-error"]');
const state = states.at(lineNumber);
expect(state.text()).toBe(errorMessage);
});
describe('when user is a terraform administrator', () => { describe('when user is a terraform administrator', () => {
beforeEach(() => { beforeEach(() => {
return createComponent({ return createComponent({
......
...@@ -27,6 +27,15 @@ describe('TerraformList', () => { ...@@ -27,6 +27,15 @@ describe('TerraformList', () => {
}, },
}; };
// Override @client _showDetails
getStatesQuery.getStates.definitions[1].selectionSet.selections[0].directives = [];
// Override @client errorMessages
getStatesQuery.getStates.definitions[1].selectionSet.selections[1].directives = [];
// Override @client loadingActions
getStatesQuery.getStates.definitions[1].selectionSet.selections[2].directives = [];
const statsQueryResponse = queryResponse || jest.fn().mockResolvedValue(apolloQueryResponse); const statsQueryResponse = queryResponse || jest.fn().mockResolvedValue(apolloQueryResponse);
const apolloProvider = createMockApollo([[getStatesQuery, statsQueryResponse]]); const apolloProvider = createMockApollo([[getStatesQuery, statsQueryResponse]]);
...@@ -52,20 +61,26 @@ describe('TerraformList', () => { ...@@ -52,20 +61,26 @@ describe('TerraformList', () => {
describe('when there is a list of terraform states', () => { describe('when there is a list of terraform states', () => {
const states = [ const states = [
{ {
_showDetails: false,
errorMessages: [],
id: 'gid://gitlab/Terraform::State/1', id: 'gid://gitlab/Terraform::State/1',
name: 'state-1', name: 'state-1',
latestVersion: null,
loadingActions: false,
lockedAt: null, lockedAt: null,
updatedAt: null,
lockedByUser: null, lockedByUser: null,
latestVersion: null, updatedAt: null,
}, },
{ {
_showDetails: false,
errorMessages: [],
id: 'gid://gitlab/Terraform::State/2', id: 'gid://gitlab/Terraform::State/2',
name: 'state-2', name: 'state-2',
latestVersion: null,
loadingActions: false,
lockedAt: null, lockedAt: null,
updatedAt: null,
lockedByUser: null, lockedByUser: null,
latestVersion: null, updatedAt: null,
}, },
]; ];
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment