Commit ff460d3c authored by Emily Ring's avatar Emily Ring Committed by Ezekiel Kigbo

Added Terraform List Page

Added new terraform index route and view
Added new js vues to display on index page
Updated translations
Started updating tests
parent f367f7df
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