Commit 3f2a31ab authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Merge branch '267147-terraform-list' into 'master'

Terraform State List

See merge request gitlab-org/gitlab!45700
parents c0dfeca4 ff460d3c
import loadTerraformVues from '~/terraform';
loadTerraformVues();
<script>
import { GlEmptyState, GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
export default {
components: {
GlEmptyState,
GlIcon,
GlLink,
GlSprintf,
},
props: {
image: {
type: String,
required: true,
},
},
};
</script>
<template>
<gl-empty-state :svg-path="image" :title="s__('Terraform|Get started with Terraform')">
<template #description>
<p>
<gl-sprintf
:message="
s__(
'Terraform|Find out how to use the %{linkStart}GitLab managed Terraform State%{linkEnd}',
)
"
>
<template #link="{ content }">
<gl-link
href="https://docs.gitlab.com/ee/user/infrastructure/index.html"
target="_blank"
>
{{ content }}
<gl-icon name="external-link" />
</gl-link>
</template>
</gl-sprintf>
</p>
</template>
</gl-empty-state>
</template>
<script>
import { GlBadge, GlIcon, GlSprintf, GlTable } from '@gitlab/ui';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
components: {
GlBadge,
GlIcon,
GlSprintf,
GlTable,
TimeAgoTooltip,
},
props: {
states: {
required: true,
type: Array,
},
},
computed: {
fields() {
return [
{
key: 'name',
thClass: 'gl-display-none',
},
{
key: 'updated',
thClass: 'gl-display-none',
tdClass: 'gl-text-right',
},
];
},
},
};
</script>
<template>
<gl-table :items="states" :fields="fields" data-testid="terraform-states-table">
<template #cell(name)="{ item }">
<p
class="gl-font-weight-bold gl-m-0 gl-text-gray-900"
data-testid="terraform-states-table-name"
>
{{ item.name }}
<gl-badge v-if="item.lockedAt">
<gl-icon name="lock" />
{{ s__('Terraform|Locked') }}
</gl-badge>
</p>
</template>
<template #cell(updated)="{ item }">
<p class="gl-m-0" data-testid="terraform-states-table-updated">
<gl-sprintf :message="s__('Terraform|updated %{timeStart}time%{timeEnd}')">
<template #time>
<time-ago-tooltip :time="item.updatedAt" />
</template>
</gl-sprintf>
</p>
</template>
</gl-table>
</template>
<script>
import { GlAlert, GlBadge, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui';
import getStatesQuery from '../graphql/queries/get_states.query.graphql';
import EmptyState from './empty_state.vue';
import StatesTable from './states_table.vue';
export default {
apollo: {
states: {
query: getStatesQuery,
variables() {
return {
projectPath: this.projectPath,
};
},
update: data => {
return {
count: data?.project?.terraformStates?.count,
list: data?.project?.terraformStates?.nodes,
};
},
error() {
this.states = null;
},
},
},
components: {
EmptyState,
GlAlert,
GlBadge,
GlLoadingIcon,
GlTab,
GlTabs,
StatesTable,
},
props: {
emptyStateImage: {
required: true,
type: String,
},
projectPath: {
required: true,
type: String,
},
},
computed: {
isLoading() {
return this.$apollo.queries.states.loading;
},
statesCount() {
return this.states?.count;
},
statesList() {
return this.states?.list;
},
},
};
</script>
<template>
<section>
<gl-tabs>
<gl-tab>
<template slot="title">
<p class="gl-m-0">
{{ s__('Terraform|States') }}
<gl-badge v-if="statesCount">{{ statesCount }}</gl-badge>
</p>
</template>
<gl-loading-icon v-if="isLoading" size="md" class="gl-mt-3" />
<div v-else-if="statesList">
<states-table v-if="statesCount" :states="statesList" />
<empty-state v-else :image="emptyStateImage" />
</div>
<gl-alert v-else variant="danger" :dismissible="false">
{{ s__('Terraform|An error occurred while loading your Terraform States') }}
</gl-alert>
</gl-tab>
</gl-tabs>
</section>
</template>
fragment State on TerraformState {
id
name
lockedAt
updatedAt
}
#import "../fragments/state.fragment.graphql"
query getStates($projectPath: ID!) {
project(fullPath: $projectPath) {
terraformStates {
count
nodes {
...State
}
}
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import TerraformList from './components/terraform_list.vue';
import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
export default () => {
const el = document.querySelector('#js-terraform-list');
if (!el) {
return null;
}
const defaultClient = createDefaultClient();
const { emptyStateImage, projectPath } = el.dataset;
return new Vue({
el,
apolloProvider: new VueApollo({ defaultClient }),
render(createElement) {
return createElement(TerraformList, {
props: {
emptyStateImage,
projectPath,
},
});
},
});
};
# frozen_string_literal: true
class Projects::TerraformController < Projects::ApplicationController
before_action :authorize_can_read_terraform_state!
feature_category :infrastructure_as_code
def index
end
private
def authorize_can_read_terraform_state!
access_denied! unless can?(current_user, :read_terraform_state, project)
end
end
# frozen_string_literal: true
module Projects::TerraformHelper
def js_terraform_list_data(project)
{
empty_state_image: image_path('illustrations/empty-state/empty-serverless-lg.svg'),
project_path: project.full_path
}
end
end
......@@ -465,6 +465,7 @@ module ProjectsHelper
builds: :read_build,
clusters: :read_cluster,
serverless: :read_cluster,
terraform: :read_terraform_state,
error_tracking: :read_sentry_issue,
alert_management: :read_alert_management_alert,
incidents: :read_issue,
......@@ -484,7 +485,8 @@ module ProjectsHelper
:read_issue,
:read_sentry_issue,
:read_cluster,
:read_feature_flag
:read_feature_flag,
:read_terraform_state
].any? do |ability|
can?(current_user, ability, project)
end
......@@ -762,6 +764,7 @@ module ProjectsHelper
metrics_dashboard
feature_flags
tracings
terraform
]
end
......
......@@ -268,6 +268,12 @@
%span
= _('Serverless')
- if project_nav_tab? :terraform
= nav_link(controller: :terraform) do
= link_to project_terraform_index_path(@project), title: _('Terraform') do
%span
= _('Terraform')
- if project_nav_tab? :clusters
- show_cluster_hint = show_gke_cluster_integration_callout?(@project)
= nav_link(controller: [:clusters, :user, :gcp]) do
......
- breadcrumb_title _('Terraform')
- page_title _('Terraform')
#js-terraform-list{ data: js_terraform_list_data(@project) }
---
title: Add new Terraform state list page
merge_request: 45700
author:
type: added
......@@ -265,6 +265,8 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
resources :functions, only: [:index]
end
resources :terraform, only: [:index]
resources :environments, except: [:destroy] do
member do
post :stop
......
......@@ -26324,6 +26324,9 @@ msgstr ""
msgid "Terms of Service and Privacy Policy"
msgstr ""
msgid "Terraform"
msgstr ""
msgid "Terraform|%{number} Terraform report failed to generate"
msgid_plural "Terraform|%{number} Terraform reports failed to generate"
msgstr[0] ""
......@@ -26340,18 +26343,36 @@ msgstr ""
msgid "Terraform|A Terraform report was generated in your pipelines."
msgstr ""
msgid "Terraform|An error occurred while loading your Terraform States"
msgstr ""
msgid "Terraform|Find out how to use the %{linkStart}GitLab managed Terraform State%{linkEnd}"
msgstr ""
msgid "Terraform|Generating the report caused an error."
msgstr ""
msgid "Terraform|Get started with Terraform"
msgstr ""
msgid "Terraform|Locked"
msgstr ""
msgid "Terraform|Reported Resource Changes: %{addNum} to add, %{changeNum} to change, %{deleteNum} to delete"
msgstr ""
msgid "Terraform|States"
msgstr ""
msgid "Terraform|The Terraform report %{name} failed to generate."
msgstr ""
msgid "Terraform|The Terraform report %{name} was generated in your pipelines."
msgstr ""
msgid "Terraform|updated %{timeStart}time%{timeEnd}"
msgstr ""
msgid "Test"
msgstr ""
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::TerraformController do
let_it_be(:project) { create(:project) }
describe 'GET index' do
subject { get :index, params: { namespace_id: project.namespace, project_id: project } }
context 'when user is authorized' do
let(:user) { project.creator }
before do
sign_in(user)
subject
end
it 'renders content' do
expect(response).to be_successful
end
end
context 'when user is unauthorized' do
let(:user) { create(:user) }
before do
project.add_guest(user)
sign_in(user)
subject
end
it 'shows 404' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Terraform', :js do
let_it_be(:project) { create(:project) }
let(:user) { project.creator }
before do
gitlab_sign_in(user)
end
context 'when user does not have any terraform states and visits index page' do
before do
visit project_terraform_index_path(project)
end
it 'sees an empty state' do
expect(page).to have_content('Get started with Terraform')
end
end
context 'when user has a terraform state' do
let_it_be(:terraform_state) { create(:terraform_state, :locked, project: project) }
context 'when user visits the index page' do
before do
visit project_terraform_index_path(project)
end
it 'displays a tab with states count' do
expect(page).to have_content("States #{project.terraform_states.size}")
end
it 'displays a table with terraform states' do
expect(page).to have_selector(
'[data-testid="terraform-states-table"] tbody tr',
count: project.terraform_states.size
)
end
it 'displays terraform information' do
expect(page).to have_content(terraform_state.name)
end
end
end
end
import { GlEmptyState, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import EmptyState from '~/terraform/components/empty_state.vue';
describe('EmptyStateComponent', () => {
let wrapper;
const propsData = {
image: '/image/path',
};
beforeEach(() => {
wrapper = shallowMount(EmptyState, { propsData, stubs: { GlEmptyState, GlSprintf } });
return wrapper.vm.$nextTick();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('should render content', () => {
expect(wrapper.find(GlEmptyState).exists()).toBe(true);
expect(wrapper.text()).toContain('Get started with Terraform');
});
});
import { GlIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { useFakeDate } from 'helpers/fake_date';
import StatesTable from '~/terraform/components/states_table.vue';
describe('StatesTable', () => {
let wrapper;
useFakeDate([2020, 10, 15]);
const propsData = {
states: [
{
name: 'state-1',
lockedAt: '2020-10-13T00:00:00Z',
updatedAt: '2020-10-13T00:00:00Z',
},
{
name: 'state-2',
lockedAt: null,
updatedAt: '2020-10-10T00:00:00Z',
},
],
};
beforeEach(() => {
wrapper = mount(StatesTable, { propsData });
return wrapper.vm.$nextTick();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it.each`
stateName | locked | lineNumber
${'state-1'} | ${true} | ${0}
${'state-2'} | ${false} | ${1}
`(
'displays the name "$stateName" for line "$lineNumber"',
({ stateName, locked, lineNumber }) => {
const states = wrapper.findAll('[data-testid="terraform-states-table-name"]');
const state = states.at(lineNumber);
expect(state.text()).toContain(stateName);
expect(state.find(GlIcon).exists()).toBe(locked);
},
);
it.each`
updateTime | lineNumber
${'updated 2 days ago'} | ${0}
${'updated 5 days ago'} | ${1}
`('displays the time "$updateTime" for line "$lineNumber"', ({ updateTime, lineNumber }) => {
const states = wrapper.findAll('[data-testid="terraform-states-table-updated"]');
const state = states.at(lineNumber);
expect(state.text()).toBe(updateTime);
});
});
import { GlAlert, GlBadge, GlLoadingIcon, GlTab } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import VueApollo from 'vue-apollo';
import EmptyState from '~/terraform/components/empty_state.vue';
import StatesTable from '~/terraform/components/states_table.vue';
import TerraformList from '~/terraform/components/terraform_list.vue';
import getStatesQuery from '~/terraform/graphql/queries/get_states.query.graphql';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('TerraformList', () => {
let wrapper;
const propsData = {
emptyStateImage: '/path/to/image',
projectPath: 'path/to/project',
};
const createWrapper = ({ terraformStates, queryResponse = null }) => {
const apolloQueryResponse = {
data: {
project: {
terraformStates,
},
},
};
const statsQueryResponse = queryResponse || jest.fn().mockResolvedValue(apolloQueryResponse);
const apolloProvider = createMockApollo([[getStatesQuery, statsQueryResponse]]);
wrapper = shallowMount(TerraformList, {
localVue,
apolloProvider,
propsData,
});
};
const findBadge = () => wrapper.find(GlBadge);
const findEmptyState = () => wrapper.find(EmptyState);
const findStatesTable = () => wrapper.find(StatesTable);
const findTab = () => wrapper.find(GlTab);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when the terraform query has succeeded', () => {
describe('when there is a list of terraform states', () => {
const states = [
{
id: 'gid://gitlab/Terraform::State/1',
name: 'state-1',
lockedAt: null,
updatedAt: null,
},
{
id: 'gid://gitlab/Terraform::State/2',
name: 'state-2',
lockedAt: null,
updatedAt: null,
},
];
beforeEach(() => {
createWrapper({
terraformStates: {
nodes: states,
count: states.length,
},
});
return wrapper.vm.$nextTick();
});
it('displays a states tab and count', () => {
expect(findTab().text()).toContain('States');
expect(findBadge().text()).toBe('2');
});
it('renders the states table', () => {
expect(findStatesTable().exists()).toBe(true);
});
});
describe('when the list of terraform states is empty', () => {
beforeEach(() => {
createWrapper({
terraformStates: {
nodes: [],
count: 0,
},
});
return wrapper.vm.$nextTick();
});
it('displays a states tab with no count', () => {
expect(findTab().text()).toContain('States');
expect(findBadge().exists()).toBe(false);
});
it('renders the empty state', () => {
expect(findEmptyState().exists()).toBe(true);
});
});
});
describe('when the terraform query has errored', () => {
beforeEach(() => {
createWrapper({ terraformStates: null, queryResponse: jest.fn().mockRejectedValue() });
return wrapper.vm.$nextTick();
});
it('displays an alert message', () => {
expect(wrapper.find(GlAlert).exists()).toBe(true);
});
});
describe('when the terraform query is loading', () => {
beforeEach(() => {
createWrapper({
terraformStates: null,
queryResponse: jest.fn().mockReturnValue(new Promise(() => {})),
});
});
it('displays a loading icon', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
});
});
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::TerraformHelper do
describe '#js_terraform_list_data' do
let_it_be(:project) { create(:project) }
subject { helper.js_terraform_list_data(project) }
it 'displays image path' do
image_path = ActionController::Base.helpers.image_path(
'illustrations/empty-state/empty-serverless-lg.svg'
)
expect(subject[:empty_state_image]).to eq(image_path)
end
it 'displays project path' do
expect(subject[:project_path]).to eq(project.full_path)
end
end
end
......@@ -72,6 +72,7 @@ RSpec.shared_context 'project navbar structure' do
_('Alerts'),
_('Incidents'),
_('Serverless'),
_('Terraform'),
_('Kubernetes'),
_('Environments'),
_('Feature Flags'),
......
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