summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-11-06 12:09:17 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-11-06 12:09:17 +0000
commit4ff56b118438f4fa6191b691fd968c75d8e94d5a (patch)
tree5b1e6ce71ee1c40a755daad006cefc3ff02bcb5e
parent0dce1c285f8d6487daf4b83be1ca9585e3a084e6 (diff)
downloadgitlab-ce-4ff56b118438f4fa6191b691fd968c75d8e94d5a.tar.gz
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue188
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue4
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_form_old.vue1
-rw-r--r--app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json48
-rw-r--r--app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json47
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/form.vue26
-rw-r--r--app/assets/javascripts/pages/projects/project.js31
-rw-r--r--app/assets/javascripts/pages/projects/terraform/index/index.js3
-rw-r--r--app/assets/javascripts/terraform/components/empty_state.vue44
-rw-r--r--app/assets/javascripts/terraform/components/states_table.vue63
-rw-r--r--app/assets/javascripts/terraform/components/terraform_list.vue85
-rw-r--r--app/assets/javascripts/terraform/graphql/fragments/state.fragment.graphql6
-rw-r--r--app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql12
-rw-r--r--app/assets/javascripts/terraform/index.js31
-rw-r--r--app/assets/stylesheets/page_bundles/alert_management_settings.scss24
-rw-r--r--app/controllers/projects/terraform_controller.rb16
-rw-r--r--app/helpers/projects/terraform_helper.rb10
-rw-r--r--app/helpers/projects_helper.rb5
-rw-r--r--app/presenters/release_presenter.rb2
-rw-r--r--app/views/admin/dev_ops_report/show.html.haml2
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml6
-rw-r--r--app/views/projects/settings/operations/_alert_management.html.haml1
-rw-r--r--app/views/projects/terraform/index.html.haml4
-rw-r--r--changelogs/unreleased/231777-switching-branches-in-repo-tree-view-navigates-backwards.yml5
-rw-r--r--changelogs/unreleased/263106-user-admin-approval-enable-disable-toggle-require_admin_approval_a.yml6
-rw-r--r--changelogs/unreleased/267147-terraform-list.yml5
-rw-r--r--changelogs/unreleased/nfriend-make-release_mr_issue_urls-enabled-by-default.yml5
-rw-r--r--config/application.rb1
-rw-r--r--config/feature_flags/development/devops_adoption_feature.yml (renamed from config/feature_flags/development/devops_adoption.yml)2
-rw-r--r--config/feature_flags/development/release_mr_issue_urls.yml4
-rw-r--r--config/initializers_before_autoloader/oj.rb (renamed from config/initializers/oj.rb)0
-rw-r--r--config/routes/project.rb2
-rw-r--r--doc/api/settings.md7
-rw-r--r--doc/user/project/merge_requests/code_quality.md15
-rw-r--r--lib/api/settings.rb1
-rw-r--r--locale/gitlab.pot39
-rw-r--r--spec/controllers/projects/terraform_controller_spec.rb38
-rw-r--r--spec/features/projects/blobs/user_creates_new_blob_in_new_project_spec.rb2
-rw-r--r--spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb4
-rw-r--r--spec/features/projects/terraform_spec.rb48
-rw-r--r--spec/frontend/alerts_settings/alert_mapping_builder_spec.js88
-rw-r--r--spec/frontend/terraform/components/empty_state_spec.js26
-rw-r--r--spec/frontend/terraform/components/states_table_spec.js62
-rw-r--r--spec/frontend/terraform/components/terraform_list_spec.js135
-rw-r--r--spec/helpers/projects/terraform_helper_spec.rb23
-rw-r--r--spec/requests/api/settings_spec.rb9
-rw-r--r--spec/support/shared_contexts/navbar_structure_context.rb1
47 files changed, 1158 insertions, 29 deletions
diff --git a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
new file mode 100644
index 00000000000..fd65d29a0f5
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
@@ -0,0 +1,188 @@
+<script>
+import Vue from 'vue';
+import {
+ GlIcon,
+ GlFormInput,
+ GlDropdown,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlTooltipDirective as GlTooltip,
+} from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+// Mocks will be removed when integrating with BE is ready
+// data format most likely will differ but UI will not
+// feature rollout plan - https://gitlab.com/gitlab-org/gitlab/-/issues/262707#note_442529171
+import gitlabFields from './mocks/gitlabFields.json';
+import parsedMapping from './mocks/parsedMapping.json';
+
+export const i18n = {
+ columns: {
+ gitlabKeyTitle: s__('AlertMappingBuilder|GitLab alert key'),
+ payloadKeyTitle: s__('AlertMappingBuilder|Payload alert key'),
+ fallbackKeyTitle: s__('AlertMappingBuilder|Define fallback'),
+ },
+ selectMappingKey: s__('AlertMappingBuilder|Select key'),
+ makeSelection: s__('AlertMappingBuilder|Make selection'),
+ fallbackTooltip: s__(
+ 'AlertMappingBuilder|Title is a required field for alerts in GitLab. Should the payload field you specified not be available, specifiy which field we should use instead. ',
+ ),
+ noResults: __('No matching results'),
+};
+
+export default {
+ i18n,
+ components: {
+ GlIcon,
+ GlFormInput,
+ GlDropdown,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ },
+ directives: {
+ GlTooltip,
+ },
+ data() {
+ return {
+ gitlabFields,
+ };
+ },
+ computed: {
+ mappingData() {
+ return this.gitlabFields.map(gitlabField => {
+ const mappingFields = parsedMapping.filter(field => field.type === gitlabField.type);
+
+ return {
+ mapping: null,
+ fallback: null,
+ searchTerm: '',
+ fallbackSearchTerm: '',
+ mappingFields,
+ ...gitlabField,
+ };
+ });
+ },
+ },
+ methods: {
+ setMapping(gitlabKey, mappingKey, valueKey) {
+ const fieldIndex = this.gitlabFields.findIndex(field => field.key === gitlabKey);
+ const updatedField = { ...this.gitlabFields[fieldIndex], ...{ [valueKey]: mappingKey } };
+ Vue.set(this.gitlabFields, fieldIndex, updatedField);
+ },
+ setSearchTerm(search = '', searchFieldKey, gitlabKey) {
+ const fieldIndex = this.gitlabFields.findIndex(field => field.key === gitlabKey);
+ const updatedField = { ...this.gitlabFields[fieldIndex], ...{ [searchFieldKey]: search } };
+ Vue.set(this.gitlabFields, fieldIndex, updatedField);
+ },
+ filterFields(searchTerm = '', fields) {
+ const search = searchTerm.toLowerCase();
+
+ return fields.filter(field => field.label.toLowerCase().includes(search));
+ },
+ isSelected(fieldValue, mapping) {
+ return fieldValue === mapping;
+ },
+ selectedValue(key) {
+ return (
+ parsedMapping.find(item => item.key === key)?.label || this.$options.i18n.makeSelection
+ );
+ },
+ getFieldValue({ label, type }) {
+ return `${label} (${type})`;
+ },
+ noResults(searchTerm, fields) {
+ return !this.filterFields(searchTerm, fields).length;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-table gl-w-full gl-mt-5">
+ <div class="gl-display-table-row">
+ <h5 class="gl-display-table-cell gl-py-3 gl-pr-3">
+ {{ $options.i18n.columns.gitlabKeyTitle }}
+ </h5>
+ <h5 class="gl-display-table-cell gl-py-3 gl-pr-3">&nbsp;</h5>
+ <h5 class="gl-display-table-cell gl-py-3 gl-pr-3">
+ {{ $options.i18n.columns.payloadKeyTitle }}
+ </h5>
+ <h5 class="gl-display-table-cell gl-py-3 gl-pr-3">
+ {{ $options.i18n.columns.fallbackKeyTitle }}
+ <gl-icon
+ v-gl-tooltip
+ name="question"
+ class="gl-text-gray-500"
+ :title="$options.i18n.fallbackTooltip"
+ />
+ </h5>
+ </div>
+ <div v-for="gitlabField in mappingData" :key="gitlabField.key" class="gl-display-table-row">
+ <div class="gl-display-table-cell gl-py-3 gl-pr-3 w-30p">
+ <gl-form-input
+ disabled
+ :value="getFieldValue(gitlabField)"
+ class="gl-bg-transparent! gl-text-gray-900!"
+ />
+ </div>
+
+ <div class="gl-display-table-cell gl-py-3 gl-pr-3">
+ <div class="right-arrow">
+ <i class="right-arrow-head"></i>
+ </div>
+ </div>
+
+ <div class="gl-display-table-cell gl-py-3 gl-pr-3 w-30p">
+ <gl-dropdown
+ :text="selectedValue(gitlabField.mapping)"
+ class="gl-w-full"
+ :header-text="$options.i18n.selectMappingKey"
+ >
+ <gl-search-box-by-type @input="setSearchTerm($event, 'searchTerm', gitlabField.key)" />
+ <gl-dropdown-item
+ v-for="mappingField in filterFields(gitlabField.searchTerm, gitlabField.mappingFields)"
+ :key="`${mappingField.key}__mapping`"
+ :is-checked="isSelected(gitlabField.mapping, mappingField.key)"
+ is-check-item
+ @click="setMapping(gitlabField.key, mappingField.key, 'mapping')"
+ >
+ {{ mappingField.label }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="noResults(gitlabField.searchTerm, gitlabField.mappingFields)">
+ >
+ {{ $options.i18n.noResults }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+
+ <div class="gl-display-table-cell gl-py-3 w-30p">
+ <gl-dropdown
+ v-if="gitlabField.hasFallback"
+ :text="selectedValue(gitlabField.fallback)"
+ class="gl-w-full"
+ :header-text="$options.i18n.selectMappingKey"
+ >
+ <gl-search-box-by-type
+ @input="setSearchTerm($event, 'fallbackSearchTerm', gitlabField.key)"
+ />
+ <gl-dropdown-item
+ v-for="mappingField in filterFields(
+ gitlabField.fallbackSearchTerm,
+ gitlabField.mappingFields,
+ )"
+ :key="`${mappingField.key}__fallback`"
+ :is-checked="isSelected(gitlabField.fallback, mappingField.key)"
+ is-check-item
+ @click="setMapping(gitlabField.key, mappingField.key, 'fallback')"
+ >
+ {{ mappingField.label }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="noResults(gitlabField.fallbackSearchTerm, gitlabField.mappingFields)"
+ >
+ {{ $options.i18n.noResults }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue
index c5322c9865e..059623ba11c 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue
@@ -12,6 +12,7 @@ import {
GlModalDirective,
GlToggle,
} from '@gitlab/ui';
+import MappingBuilder from './alert_mapping_builder.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -84,6 +85,7 @@ export default {
GlModal,
GlToggle,
AlertSettingsFormHelpBlock,
+ MappingBuilder,
},
directives: {
'gl-modal': GlModalDirective,
@@ -344,7 +346,7 @@ export default {
label-for="mapping-builder"
>
<span class="gl-text-gray-500">{{ $options.i18n.integrationFormSteps.step5.intro }}</span>
- <!--mapping builder will be added here-->
+ <mapping-builder />
</gl-form-group>
<div class="gl-display-flex gl-justify-content-end">
<gl-button type="reset" class="gl-mr-3 js-no-auto-disable">{{ __('Cancel') }}</gl-button>
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form_old.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form_old.vue
index e4ba87c4e14..0246315bdc5 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form_old.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form_old.vue
@@ -477,6 +477,7 @@ export default {
max-rows="10"
/>
</gl-form-group>
+
<gl-button :disabled="!canTestAlert" @click="validateTestAlert">{{
$options.i18n.testAlertInfo
}}</gl-button>
diff --git a/app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json b/app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json
new file mode 100644
index 00000000000..42810b101be
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json
@@ -0,0 +1,48 @@
+[
+ {
+ "key":"title",
+ "label":"Title",
+ "type":"String",
+ "hasFallback": true
+ },
+ {
+ "key":"description",
+ "label":"Description",
+ "type":"String"
+ },
+ {
+ "key":"startTime",
+ "label":"Start time",
+ "type":"DateTime"
+ },
+ {
+ "key":"service",
+ "label":"Service",
+ "type":"String"
+ },
+ {
+ "key":"monitoringTool",
+ "label":"Monitoring tool",
+ "type":"String"
+ },
+ {
+ "key":"hosts",
+ "label":"Hosts",
+ "type":"String or Array"
+ },
+ {
+ "key":"severity",
+ "label":"Severity",
+ "type":"String"
+ },
+ {
+ "key":"fingerprint",
+ "label":"Fingerprint",
+ "type":"String"
+ },
+ {
+ "key":"environment",
+ "label":"Environment",
+ "type":"String"
+ }
+]
diff --git a/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json b/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json
new file mode 100644
index 00000000000..3041e7d64cf
--- /dev/null
+++ b/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json
@@ -0,0 +1,47 @@
+[
+ {
+ "key":"title",
+ "label":"Title",
+ "type":"String"
+ },
+ {
+ "key":"description",
+ "label":"Description",
+ "type":"String"
+ },
+ {
+ "key":"startTime",
+ "label":"Start time",
+ "type":"DateTime"
+ },
+ {
+ "key":"service",
+ "label":"Service",
+ "type":"String"
+ },
+ {
+ "key":"monitoringTool",
+ "label":"Monitoring tool",
+ "type":"String"
+ },
+ {
+ "key":"hosts",
+ "label":"Hosts",
+ "type":"String or Array"
+ },
+ {
+ "key":"severity",
+ "label":"Severity",
+ "type":"String"
+ },
+ {
+ "key":"fingerprint",
+ "label":"Fingerprint",
+ "type":"String"
+ },
+ {
+ "key":"environment",
+ "label":"Environment",
+ "type":"String"
+ }
+]
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
index f36fe87ccfa..9d2deb1d4d0 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
@@ -1,8 +1,7 @@
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
-import { GlModal, GlSafeHtmlDirective } from '@gitlab/ui';
+import { GlModal, GlSafeHtmlDirective, GlButton } from '@gitlab/ui';
import { n__, __ } from '~/locale';
-import LoadingButton from '~/vue_shared/components/loading_button.vue';
import CommitMessageField from './message_field.vue';
import Actions from './actions.vue';
import SuccessMessage from './success_message.vue';
@@ -12,10 +11,10 @@ import { createUnexpectedCommitError } from '../../lib/errors';
export default {
components: {
Actions,
- LoadingButton,
CommitMessageField,
SuccessMessage,
GlModal,
+ GlButton,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
@@ -156,12 +155,16 @@ export default {
/>
<div class="clearfix gl-mt-5">
<actions />
- <loading-button
+ <gl-button
:loading="submitCommitLoading"
- :label="commitButtonText"
- container-class="btn btn-success btn-sm float-left qa-commit-button"
+ class="float-left qa-commit-button"
+ size="small"
+ category="primary"
+ variant="success"
@click="commit"
- />
+ >
+ {{ __('Commit') }}
+ </gl-button>
<button
v-if="!discardDraftButtonDisabled"
type="button"
@@ -170,14 +173,17 @@ export default {
>
{{ __('Discard draft') }}
</button>
- <button
+ <gl-button
v-else
type="button"
- class="btn btn-default btn-sm float-right"
+ class="float-right"
+ category="secondary"
+ variant="default"
+ size="small"
@click="toggleIsCompact"
>
{{ __('Collapse') }}
- </button>
+ </gl-button>
</div>
<gl-modal
ref="commitErrorModal"
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index 2f27814a692..5317093c4cf 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -57,7 +57,7 @@ export default class Project {
$('.project-refs-select').on('change', function() {
return $(this)
.parents('form')
- .submit();
+ .trigger('submit');
});
}
@@ -156,11 +156,32 @@ export default class Project {
},
clicked(options) {
const { e } = options;
- if (!shouldVisit) {
- e.preventDefault();
+ e.preventDefault();
+
+ // Since this page does not reload when changing directories in a repo
+ // the rendered links do not have the path to the current directory.
+ // This updates the path based on the current url and then opens
+ // the the url with the updated path parameter.
+ if (shouldVisit) {
+ const selectedUrl = new URL(e.target.href);
+ const loc = window.location.href;
+
+ if (loc.includes('/-/')) {
+ const refs = this.fullData.Branches.concat(this.fullData.Tags);
+ const currentRef = refs.find(ref => loc.indexOf(ref) > -1);
+ if (currentRef) {
+ const targetPath = loc.split(currentRef)[1].slice(1);
+ selectedUrl.searchParams.set('path', targetPath);
+ }
+ }
+
+ // Open in new window if "meta" key is pressed
+ if (e.metaKey) {
+ window.open(selectedUrl.href, '_blank');
+ } else {
+ window.location.href = selectedUrl.href;
+ }
}
- /* The actual process is removed since `link.href` in `RenderRow` contains the full target.
- * It makes the visitable link can be visited when opening on a new tab of browser */
},
});
});
diff --git a/app/assets/javascripts/pages/projects/terraform/index/index.js b/app/assets/javascripts/pages/projects/terraform/index/index.js
new file mode 100644
index 00000000000..6f9f820f8e1
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/terraform/index/index.js
@@ -0,0 +1,3 @@
+import loadTerraformVues from '~/terraform';
+
+loadTerraformVues();
diff --git a/app/assets/javascripts/terraform/components/empty_state.vue b/app/assets/javascripts/terraform/components/empty_state.vue
new file mode 100644
index 00000000000..d86ba3af2b1
--- /dev/null
+++ b/app/assets/javascripts/terraform/components/empty_state.vue
@@ -0,0 +1,44 @@
+<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>
diff --git a/app/assets/javascripts/terraform/components/states_table.vue b/app/assets/javascripts/terraform/components/states_table.vue
new file mode 100644
index 00000000000..1fed5158027
--- /dev/null
+++ b/app/assets/javascripts/terraform/components/states_table.vue
@@ -0,0 +1,63 @@
+<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>
diff --git a/app/assets/javascripts/terraform/components/terraform_list.vue b/app/assets/javascripts/terraform/components/terraform_list.vue
new file mode 100644
index 00000000000..bd15156334f
--- /dev/null
+++ b/app/assets/javascripts/terraform/components/terraform_list.vue
@@ -0,0 +1,85 @@
+<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>
diff --git a/app/assets/javascripts/terraform/graphql/fragments/state.fragment.graphql b/app/assets/javascripts/terraform/graphql/fragments/state.fragment.graphql
new file mode 100644
index 00000000000..ea9a569b1ee
--- /dev/null
+++ b/app/assets/javascripts/terraform/graphql/fragments/state.fragment.graphql
@@ -0,0 +1,6 @@
+fragment State on TerraformState {
+ id
+ name
+ lockedAt
+ updatedAt
+}
diff --git a/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql b/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql
new file mode 100644
index 00000000000..0ffcbc4e900
--- /dev/null
+++ b/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql
@@ -0,0 +1,12 @@
+#import "../fragments/state.fragment.graphql"
+
+query getStates($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ terraformStates {
+ count
+ nodes {
+ ...State
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/terraform/index.js b/app/assets/javascripts/terraform/index.js
new file mode 100644
index 00000000000..579d2d14023
--- /dev/null
+++ b/app/assets/javascripts/terraform/index.js
@@ -0,0 +1,31 @@
+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,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/stylesheets/page_bundles/alert_management_settings.scss b/app/assets/stylesheets/page_bundles/alert_management_settings.scss
new file mode 100644
index 00000000000..fb7c1602cba
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/alert_management_settings.scss
@@ -0,0 +1,24 @@
+@import 'mixins_and_variables_and_functions';
+
+$stroke-size: 1px;
+
+.right-arrow {
+ @include gl-relative;
+ @include gl-w-full;
+ height: $stroke-size;
+ @include gl-display-inline-block;
+ background-color: var(--gray-400, $gray-400);
+ min-width: $gl-spacing-scale-5;
+
+ &-head {
+ @include gl-absolute;
+ top: -$gl-spacing-scale-2;
+ left: calc(100% - #{$gl-spacing-scale-3} - #{2 * $stroke-size});
+ border-color: var(--gray-400, $gray-400);
+ @include gl-border-solid;
+ border-width: 0 $stroke-size $stroke-size 0;
+ @include gl-display-inline-block;
+ @include gl-p-2;
+ transform: rotate(-45deg);
+ }
+}
diff --git a/app/controllers/projects/terraform_controller.rb b/app/controllers/projects/terraform_controller.rb
new file mode 100644
index 00000000000..aef163c98c5
--- /dev/null
+++ b/app/controllers/projects/terraform_controller.rb
@@ -0,0 +1,16 @@
+# 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
diff --git a/app/helpers/projects/terraform_helper.rb b/app/helpers/projects/terraform_helper.rb
new file mode 100644
index 00000000000..b286bc4d7a5
--- /dev/null
+++ b/app/helpers/projects/terraform_helper.rb
@@ -0,0 +1,10 @@
+# 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
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index ae46135e890..9d1f685960f 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -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
diff --git a/app/presenters/release_presenter.rb b/app/presenters/release_presenter.rb
index 2e85ab2bb94..9d7e183aaf3 100644
--- a/app/presenters/release_presenter.rb
+++ b/app/presenters/release_presenter.rb
@@ -82,7 +82,7 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated
end
def release_mr_issue_urls_available?
- ::Feature.enabled?(:release_mr_issue_urls, project)
+ ::Feature.enabled?(:release_mr_issue_urls, project, default_enabled: true)
end
def release_edit_page_available?
diff --git a/app/views/admin/dev_ops_report/show.html.haml b/app/views/admin/dev_ops_report/show.html.haml
index ad9f0c6f776..dc3bda3a994 100644
--- a/app/views/admin/dev_ops_report/show.html.haml
+++ b/app/views/admin/dev_ops_report/show.html.haml
@@ -3,7 +3,7 @@
.container
.gl-mt-3
- - if Feature.enabled?(:devops_adoption)
+ - if Gitlab.ee? && Feature.enabled?(:devops_adoption_feature) && License.feature_available?(:devops_adoption)
= render_if_exists 'admin/dev_ops_report/devops_tabs'
- else
= render 'report'
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 95f9dfb8bbe..d1168437c5d 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -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
diff --git a/app/views/projects/settings/operations/_alert_management.html.haml b/app/views/projects/settings/operations/_alert_management.html.haml
index 5c16a5e2758..9e76ad52ecb 100644
--- a/app/views/projects/settings/operations/_alert_management.html.haml
+++ b/app/views/projects/settings/operations/_alert_management.html.haml
@@ -1,5 +1,6 @@
- return unless can?(current_user, :admin_operations, @project)
- expanded = expanded_by_default?
+- add_page_specific_style 'page_bundles/alert_management_settings'
%section.settings.no-animate#js-alert-management-settings{ class: ('expanded' if expanded) }
.settings-header
diff --git a/app/views/projects/terraform/index.html.haml b/app/views/projects/terraform/index.html.haml
new file mode 100644
index 00000000000..136e7ded224
--- /dev/null
+++ b/app/views/projects/terraform/index.html.haml
@@ -0,0 +1,4 @@
+- breadcrumb_title _('Terraform')
+- page_title _('Terraform')
+
+#js-terraform-list{ data: js_terraform_list_data(@project) }
diff --git a/changelogs/unreleased/231777-switching-branches-in-repo-tree-view-navigates-backwards.yml b/changelogs/unreleased/231777-switching-branches-in-repo-tree-view-navigates-backwards.yml
new file mode 100644
index 00000000000..9212cb18331
--- /dev/null
+++ b/changelogs/unreleased/231777-switching-branches-in-repo-tree-view-navigates-backwards.yml
@@ -0,0 +1,5 @@
+---
+title: Fix loading current directory when changing branches
+merge_request: 46479
+author:
+type: fixed
diff --git a/changelogs/unreleased/263106-user-admin-approval-enable-disable-toggle-require_admin_approval_a.yml b/changelogs/unreleased/263106-user-admin-approval-enable-disable-toggle-require_admin_approval_a.yml
new file mode 100644
index 00000000000..1532833cfed
--- /dev/null
+++ b/changelogs/unreleased/263106-user-admin-approval-enable-disable-toggle-require_admin_approval_a.yml
@@ -0,0 +1,6 @@
+---
+title: Allow setting the value of 'require_admin_approval_after_user_signup' via Settings
+ API
+merge_request: 46851
+author:
+type: added
diff --git a/changelogs/unreleased/267147-terraform-list.yml b/changelogs/unreleased/267147-terraform-list.yml
new file mode 100644
index 00000000000..22db29c8bb0
--- /dev/null
+++ b/changelogs/unreleased/267147-terraform-list.yml
@@ -0,0 +1,5 @@
+---
+title: Add new Terraform state list page
+merge_request: 45700
+author:
+type: added
diff --git a/changelogs/unreleased/nfriend-make-release_mr_issue_urls-enabled-by-default.yml b/changelogs/unreleased/nfriend-make-release_mr_issue_urls-enabled-by-default.yml
new file mode 100644
index 00000000000..820cc5a7af0
--- /dev/null
+++ b/changelogs/unreleased/nfriend-make-release_mr_issue_urls-enabled-by-default.yml
@@ -0,0 +1,5 @@
+---
+title: Enable issue and MR stat links on release progress review
+merge_request: 46910
+author:
+type: added
diff --git a/config/application.rb b/config/application.rb
index b01bd92e5e1..22259b95cdc 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -200,6 +200,7 @@ module Gitlab
config.assets.precompile << "page_bundles/reports.css"
config.assets.precompile << "page_bundles/wiki.css"
config.assets.precompile << "page_bundles/xterm.css"
+ config.assets.precompile << "page_bundles/alert_management_settings.css"
config.assets.precompile << "lazy_bundles/cropper.css"
config.assets.precompile << "performance_bar.css"
config.assets.precompile << "disable_animations.css"
diff --git a/config/feature_flags/development/devops_adoption.yml b/config/feature_flags/development/devops_adoption_feature.yml
index af9e9fae9f5..060e87d5a16 100644
--- a/config/feature_flags/development/devops_adoption.yml
+++ b/config/feature_flags/development/devops_adoption_feature.yml
@@ -1,5 +1,5 @@
---
-name: devops_adoption
+name: devops_adoption_feature
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46005
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/271568
type: development
diff --git a/config/feature_flags/development/release_mr_issue_urls.yml b/config/feature_flags/development/release_mr_issue_urls.yml
index 3f9c81b8fe9..0f68ec1946a 100644
--- a/config/feature_flags/development/release_mr_issue_urls.yml
+++ b/config/feature_flags/development/release_mr_issue_urls.yml
@@ -1,7 +1,7 @@
---
name: release_mr_issue_urls
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/18727
-rollout_issue_url:
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/276619
group: group::release management
type: development
-default_enabled: false
+default_enabled: true
diff --git a/config/initializers/oj.rb b/config/initializers_before_autoloader/oj.rb
index 3fa26259fc6..3fa26259fc6 100644
--- a/config/initializers/oj.rb
+++ b/config/initializers_before_autoloader/oj.rb
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 33a96345a93..30e9bfb4bfd 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -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
diff --git a/doc/api/settings.md b/doc/api/settings.md
index 3885d236a72..fdce87aec78 100644
--- a/doc/api/settings.md
+++ b/doc/api/settings.md
@@ -79,7 +79,8 @@ Example response:
"snippet_size_limit": 52428800,
"issues_create_limit": 300,
"raw_blob_request_limit": 300,
- "wiki_page_max_content_bytes": 52428800
+ "wiki_page_max_content_bytes": 52428800,
+ "require_admin_approval_after_user_signup": false
}
```
@@ -170,7 +171,8 @@ Example response:
"snippet_size_limit": 52428800,
"issues_create_limit": 300,
"raw_blob_request_limit": 300,
- "wiki_page_max_content_bytes": 52428800
+ "wiki_page_max_content_bytes": 52428800,
+ "require_admin_approval_after_user_signup": false
}
```
@@ -331,6 +333,7 @@ listed in the descriptions of the relevant settings.
| `repository_size_limit` | integer | no | **(PREMIUM)** Size limit per repository (MB) |
| `repository_storages_weighted` | hash of strings to integers | no | (GitLab 13.1 and later) Hash of names of taken from `gitlab.yml` to [weights](../administration/repository_storage_paths.md#choose-where-new-repositories-will-be-stored). New projects are created in one of these stores, chosen by a weighted random selection. |
| `repository_storages` | array of strings | no | (GitLab 13.0 and earlier) List of names of enabled storage paths, taken from `gitlab.yml`. New projects are created in one of these stores, chosen at random. |
+| `require_admin_approval_after_user_signup` | boolean | no | When enabled, any user that signs up for an account using the registration form is placed under a **Pending approval** state and has to be explicitly [approved](../user/admin_area/approving_users.md) by an administrator. |
| `require_two_factor_authentication` | boolean | no | (**If enabled, requires:** `two_factor_grace_period`) Require all users to set up Two-factor authentication. |
| `restricted_visibility_levels` | array of strings | no | Selected levels cannot be used by non-admin users for groups, projects or snippets. Can take `private`, `internal` and `public` as a parameter. Default is `null` which means there is no restriction. |
| `rsa_key_restriction` | integer | no | The minimum allowed bit length of an uploaded RSA key. Default is `0` (no restriction). `-1` disables RSA keys. |
diff --git a/doc/user/project/merge_requests/code_quality.md b/doc/user/project/merge_requests/code_quality.md
index 2299e9d5eab..f3a676e2b48 100644
--- a/doc/user/project/merge_requests/code_quality.md
+++ b/doc/user/project/merge_requests/code_quality.md
@@ -284,6 +284,21 @@ code_quality:
paths: [gl-code-quality-report.html]
```
+It's also possible to generate both JSON and HTML report files by defining
+another job and using `extends: code_quality`:
+
+```yaml
+include:
+ - template: Code-Quality.gitlab-ci.yml
+
+code_quality_html:
+ extends: code_quality
+ variables:
+ REPORT_FORMAT: html
+ artifacts:
+ paths: [gl-code-quality-report.html]
+```
+
## Extending functionality
### Using Analysis Plugins
diff --git a/lib/api/settings.rb b/lib/api/settings.rb
index b40af368f36..16ac4ec4ef2 100644
--- a/lib/api/settings.rb
+++ b/lib/api/settings.rb
@@ -159,6 +159,7 @@ module API
optional :issues_create_limit, type: Integer, desc: "Maximum number of issue creation requests allowed per minute per user. Set to 0 for unlimited requests per minute."
optional :raw_blob_request_limit, type: Integer, desc: "Maximum number of requests per minute for each raw path. Set to 0 for unlimited requests per minute."
optional :wiki_page_max_content_bytes, type: Integer, desc: "Maximum wiki page content size in bytes"
+ optional :require_admin_approval_after_user_signup, type: Boolean, desc: 'Require explicit admin approval for new signups'
ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type|
optional :"#{type}_key_restriction",
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 30f4b38412b..eb546397436 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -2488,6 +2488,24 @@ msgstr ""
msgid "AlertManagement|You have enabled the Opsgenie integration. Your alerts will be visible directly in Opsgenie."
msgstr ""
+msgid "AlertMappingBuilder|Define fallback"
+msgstr ""
+
+msgid "AlertMappingBuilder|GitLab alert key"
+msgstr ""
+
+msgid "AlertMappingBuilder|Make selection"
+msgstr ""
+
+msgid "AlertMappingBuilder|Payload alert key"
+msgstr ""
+
+msgid "AlertMappingBuilder|Select key"
+msgstr ""
+
+msgid "AlertMappingBuilder|Title is a required field for alerts in GitLab. Should the payload field you specified not be available, specifiy which field we should use instead. "
+msgstr ""
+
msgid "AlertService|Review your external service's documentation to learn where to provide this information to your external service, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint."
msgstr ""
@@ -26324,6 +26342,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 +26361,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 ""
diff --git a/spec/controllers/projects/terraform_controller_spec.rb b/spec/controllers/projects/terraform_controller_spec.rb
new file mode 100644
index 00000000000..1978b9494fa
--- /dev/null
+++ b/spec/controllers/projects/terraform_controller_spec.rb
@@ -0,0 +1,38 @@
+# 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
diff --git a/spec/features/projects/blobs/user_creates_new_blob_in_new_project_spec.rb b/spec/features/projects/blobs/user_creates_new_blob_in_new_project_spec.rb
index fda2992af8d..6b9fd41059d 100644
--- a/spec/features/projects/blobs/user_creates_new_blob_in_new_project_spec.rb
+++ b/spec/features/projects/blobs/user_creates_new_blob_in_new_project_spec.rb
@@ -23,8 +23,6 @@ RSpec.describe 'User creates new blob', :js do
ide_commit
- click_button('Commit')
-
expect(page).to have_content('All changes are committed')
expect(project.repository.blob_at('master', 'dummy-file').data).to eql("Hello world\n")
end
diff --git a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb
index d28e31c08dc..42f8daf9d5e 100644
--- a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb
+++ b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb
@@ -27,9 +27,7 @@ RSpec.describe 'Projects > Files > Project owner sees a link to create a license
ide_commit
- click_button('Commit')
-
- expect(current_path).to eq("/-/ide/project/#{project.full_path}/tree/master/-/")
+ expect(current_path).to eq("/-/ide/project/#{project.full_path}/tree/master/-/LICENSE/")
expect(page).to have_content('All changes are committed')
diff --git a/spec/features/projects/terraform_spec.rb b/spec/features/projects/terraform_spec.rb
new file mode 100644
index 00000000000..2680dfb2b13
--- /dev/null
+++ b/spec/features/projects/terraform_spec.rb
@@ -0,0 +1,48 @@
+# 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
diff --git a/spec/frontend/alerts_settings/alert_mapping_builder_spec.js b/spec/frontend/alerts_settings/alert_mapping_builder_spec.js
new file mode 100644
index 00000000000..a75422b8c48
--- /dev/null
+++ b/spec/frontend/alerts_settings/alert_mapping_builder_spec.js
@@ -0,0 +1,88 @@
+import { GlIcon, GlFormInput, GlDropdown, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import AlertMappingBuilder, { i18n } from '~/alerts_settings/components/alert_mapping_builder.vue';
+import gitlabFields from '~/alerts_settings/components/mocks/gitlabFields.json';
+import parsedMapping from '~/alerts_settings/components/mocks/parsedMapping.json';
+
+describe('AlertMappingBuilder', () => {
+ let wrapper;
+
+ function mountComponent() {
+ wrapper = shallowMount(AlertMappingBuilder);
+ }
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ const findColumnInRow = (row, column) =>
+ wrapper
+ .findAll('.gl-display-table-row')
+ .at(row)
+ .findAll('.gl-display-table-cell ')
+ .at(column);
+
+ const fieldsByTypeCount = parsedMapping.reduce((acc, { type }) => {
+ acc[type] = (acc[type] || 0) + 1;
+ return acc;
+ }, {});
+
+ it('renders column captions', () => {
+ expect(findColumnInRow(0, 0).text()).toContain(i18n.columns.gitlabKeyTitle);
+ expect(findColumnInRow(0, 2).text()).toContain(i18n.columns.payloadKeyTitle);
+ expect(findColumnInRow(0, 3).text()).toContain(i18n.columns.fallbackKeyTitle);
+
+ const fallbackColumnIcon = findColumnInRow(0, 3).find(GlIcon);
+ expect(fallbackColumnIcon.exists()).toBe(true);
+ expect(fallbackColumnIcon.attributes('name')).toBe('question');
+ expect(fallbackColumnIcon.attributes('title')).toBe(i18n.fallbackTooltip);
+ });
+
+ it('renders disabled form input for each mapped field', () => {
+ gitlabFields.forEach((field, index) => {
+ const input = findColumnInRow(index + 1, 0).find(GlFormInput);
+ expect(input.attributes('value')).toBe(`${field.label} (${field.type})`);
+ expect(input.attributes('disabled')).toBe('');
+ });
+ });
+
+ it('renders right arrow next to each input', () => {
+ gitlabFields.forEach((field, index) => {
+ const arrow = findColumnInRow(index + 1, 1).find('.right-arrow');
+ expect(arrow.exists()).toBe(true);
+ });
+ });
+
+ it('renders mapping dropdown for each field', () => {
+ gitlabFields.forEach(({ type }, index) => {
+ const dropdown = findColumnInRow(index + 1, 2).find(GlDropdown);
+ const searchBox = dropdown.find(GlSearchBoxByType);
+ const dropdownItems = dropdown.findAll(GlDropdownItem);
+
+ expect(dropdown.exists()).toBe(true);
+ expect(searchBox.exists()).toBe(true);
+ expect(dropdownItems.length).toBe(fieldsByTypeCount[type]);
+ });
+ });
+
+ it('renders fallback dropdown only for the fields that have fallback', () => {
+ gitlabFields.forEach(({ type, hasFallback }, index) => {
+ const dropdown = findColumnInRow(index + 1, 3).find(GlDropdown);
+ expect(dropdown.exists()).toBe(Boolean(hasFallback));
+
+ if (hasFallback) {
+ const searchBox = dropdown.find(GlSearchBoxByType);
+ const dropdownItems = dropdown.findAll(GlDropdownItem);
+ expect(searchBox.exists()).toBe(hasFallback);
+ expect(dropdownItems.length).toBe(fieldsByTypeCount[type]);
+ }
+ });
+ });
+});
diff --git a/spec/frontend/terraform/components/empty_state_spec.js b/spec/frontend/terraform/components/empty_state_spec.js
new file mode 100644
index 00000000000..c86160e18f3
--- /dev/null
+++ b/spec/frontend/terraform/components/empty_state_spec.js
@@ -0,0 +1,26 @@
+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');
+ });
+});
diff --git a/spec/frontend/terraform/components/states_table_spec.js b/spec/frontend/terraform/components/states_table_spec.js
new file mode 100644
index 00000000000..e50969b2c83
--- /dev/null
+++ b/spec/frontend/terraform/components/states_table_spec.js
@@ -0,0 +1,62 @@
+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);
+ });
+});
diff --git a/spec/frontend/terraform/components/terraform_list_spec.js b/spec/frontend/terraform/components/terraform_list_spec.js
new file mode 100644
index 00000000000..7eeaf31f3f7
--- /dev/null
+++ b/spec/frontend/terraform/components/terraform_list_spec.js
@@ -0,0 +1,135 @@
+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);
+ });
+ });
+});
diff --git a/spec/helpers/projects/terraform_helper_spec.rb b/spec/helpers/projects/terraform_helper_spec.rb
new file mode 100644
index 00000000000..de363c42d21
--- /dev/null
+++ b/spec/helpers/projects/terraform_helper_spec.rb
@@ -0,0 +1,23 @@
+# 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
diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb
index e551db3552e..916d8dec8c2 100644
--- a/spec/requests/api/settings_spec.rb
+++ b/spec/requests/api/settings_spec.rb
@@ -40,6 +40,7 @@ RSpec.describe API::Settings, 'Settings' do
expect(json_response['spam_check_endpoint_enabled']).to be_falsey
expect(json_response['spam_check_endpoint_url']).to be_nil
expect(json_response['wiki_page_max_content_bytes']).to be_a(Integer)
+ expect(json_response['require_admin_approval_after_user_signup']).to eq(false)
end
end
@@ -423,6 +424,14 @@ RSpec.describe API::Settings, 'Settings' do
expect(json_response['abuse_notification_email']).to eq('test@example.com')
end
+ it 'supports setting require_admin_approval_after_user_signup' do
+ put api('/application/settings', admin),
+ params: { require_admin_approval_after_user_signup: true }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['require_admin_approval_after_user_signup']).to eq(true)
+ end
+
context "missing sourcegraph_url value when sourcegraph_enabled is true" do
it "returns a blank parameter error message" do
put api("/application/settings", admin), params: { sourcegraph_enabled: true }
diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb
index d304a3e9a0d..ed74c3f179f 100644
--- a/spec/support/shared_contexts/navbar_structure_context.rb
+++ b/spec/support/shared_contexts/navbar_structure_context.rb
@@ -72,6 +72,7 @@ RSpec.shared_context 'project navbar structure' do
_('Alerts'),
_('Incidents'),
_('Serverless'),
+ _('Terraform'),
_('Kubernetes'),
_('Environments'),
_('Feature Flags'),