Commit b610767b authored by Sri's avatar Sri Committed by Paul Slaughter

Improvements to `Project::Infra::Google Cloud`

- Feature flag `incubation_5mp_google_cloud` bound to Project
  This allows for easier testing on gitlab.com where the feature
  flag may be enabled for select projects.

- `service_accounts` is an active route for `google_cloud` menu item
  This expands the appropriate project sidemenu item when creating
  a service account

- Introduce a top level `app.vue` component
  Achieves consistency with a majority of frontend code in the repo
  Where a single route with a single entry point serves multiple templates
  Instead of routing the frontend in `index.js` use a top level component
  to render the appropriate `screen`

https://gitlab.com/gitlab-org/gitlab/-/merge_requests/75295
parent 58a84211
<script> <script>
import { GlTab, GlTabs } from '@gitlab/ui'; import { __ } from '~/locale';
import IncubationBanner from '../incubation_banner.vue';
import ServiceAccountsList from '../service_accounts_list.vue'; import Home from './home.vue';
import IncubationBanner from './incubation_banner.vue';
import ServiceAccountsForm from './service_accounts_form.vue';
import NoGcpProjects from './errors/no_gcp_projects.vue';
import GcpError from './errors/gcp_error.vue';
const SCREEN_GCP_ERROR = 'gcp_error';
const SCREEN_HOME = 'home';
const SCREEN_NO_GCP_PROJECTS = 'no_gcp_projects';
const SCREEN_SERVICE_ACCOUNTS_FORM = 'service_accounts_form';
export default { export default {
components: { GlTab, GlTabs, IncubationBanner, ServiceAccountsList }, components: {
IncubationBanner,
},
inheritAttrs: false,
props: { props: {
serviceAccounts: { screen: {
type: Array,
required: true, required: true,
},
createServiceAccountUrl: {
type: String, type: String,
required: true,
}, },
emptyIllustrationUrl: { },
type: String, computed: {
required: true, mainComponent() {
switch (this.screen) {
case SCREEN_HOME:
return Home;
case SCREEN_GCP_ERROR:
return GcpError;
case SCREEN_NO_GCP_PROJECTS:
return NoGcpProjects;
case SCREEN_SERVICE_ACCOUNTS_FORM:
return ServiceAccountsForm;
default:
throw new Error(__('Unknown screen'));
}
}, },
}, },
methods: { methods: {
...@@ -34,17 +54,6 @@ export default { ...@@ -34,17 +54,6 @@ export default {
:report-bug-url="feedbackUrl('report_bug')" :report-bug-url="feedbackUrl('report_bug')"
:feature-request-url="feedbackUrl('feature_request')" :feature-request-url="feedbackUrl('feature_request')"
/> />
<gl-tabs> <component :is="mainComponent" v-bind="$attrs" />
<gl-tab :title="__('Configuration')">
<service-accounts-list
class="gl-mx-3"
:list="serviceAccounts"
:create-url="createServiceAccountUrl"
:empty-illustration-url="emptyIllustrationUrl"
/>
</gl-tab>
<gl-tab :title="__('Deployments')" disabled />
<gl-tab :title="__('Services')" disabled />
</gl-tabs>
</div> </div>
</template> </template>
<script>
import { GlTabs, GlTab } from '@gitlab/ui';
import ServiceAccountsList from './service_accounts_list.vue';
export default {
components: {
GlTabs,
GlTab,
ServiceAccountsList,
},
props: {
serviceAccounts: {
type: Array,
required: true,
},
createServiceAccountUrl: {
type: String,
required: true,
},
emptyIllustrationUrl: {
type: String,
required: true,
},
},
};
</script>
<template>
<gl-tabs>
<gl-tab :title="__('Configuration')">
<service-accounts-list
class="gl-mx-4"
:list="serviceAccounts"
:create-url="createServiceAccountUrl"
:empty-illustration-url="emptyIllustrationUrl"
/>
</gl-tab>
<gl-tab :title="__('Deployments')" disabled />
<gl-tab :title="__('Services')" disabled />
</gl-tabs>
</template>
<script> <script>
import { GlButton, GlFormGroup, GlFormSelect } from '@gitlab/ui'; import { GlButton, GlFormGroup, GlFormSelect } from '@gitlab/ui';
import { __ } from '~/locale'; import { __ } from '~/locale';
import IncubationBanner from '../incubation_banner.vue';
export default { export default {
components: { GlButton, GlFormGroup, GlFormSelect, IncubationBanner }, components: { GlButton, GlFormGroup, GlFormSelect },
props: { props: {
gcpProjects: { required: true, type: Array }, gcpProjects: { required: true, type: Array },
environments: { required: true, type: Array }, environments: { required: true, type: Array },
cancelPath: { required: true, type: String }, cancelPath: { required: true, type: String },
}, },
methods: {
feedbackUrl(template) {
return `https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/meta/-/issues/new?issuable_template=${template}`;
},
},
i18n: { i18n: {
title: __('Create service account'), title: __('Create service account'),
gcpProjectLabel: __('Google Cloud project'), gcpProjectLabel: __('Google Cloud project'),
...@@ -31,11 +25,6 @@ export default { ...@@ -31,11 +25,6 @@ export default {
<template> <template>
<div> <div>
<incubation-banner
:share-feedback-url="feedbackUrl('general_feedback')"
:report-bug-url="feedbackUrl('report_bug')"
:feature-request-url="feedbackUrl('feature_request')"
/>
<header class="gl-my-5 gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid"> <header class="gl-my-5 gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid">
<h2 class="gl-font-size-h1">{{ $options.i18n.title }}</h2> <h2 class="gl-font-size-h1">{{ $options.i18n.title }}</h2>
</header> </header>
......
import Vue from 'vue'; import Vue from 'vue';
import { __ } from '~/locale'; import App from './components/app.vue';
import App from './components/screens/app.vue';
import ServiceAccountsForm from './components/screens/service_accounts_form.vue';
import ErrorNoGcpProjects from './components/errors/no_gcp_projects.vue';
import ErrorGcpError from './components/errors/gcp_error.vue';
const elementRenderer = (element, props = {}) => (createElement) =>
createElement(element, { props });
const rootComponentMap = [
{
root: '#js-google-cloud-error-no-gcp-projects',
component: ErrorNoGcpProjects,
},
{
root: '#js-google-cloud-error-gcp-error',
component: ErrorGcpError,
},
{
root: '#js-google-cloud-service-accounts',
component: ServiceAccountsForm,
},
{
root: '#js-google-cloud',
component: App,
},
];
export default () => { export default () => {
for (let i = 0; i < rootComponentMap.length; i += 1) { const root = '#js-google-cloud';
const { root, component } = rootComponentMap[i]; const element = document.querySelector(root);
const element = document.querySelector(root); const { screen, ...attrs } = JSON.parse(element.getAttribute('data'));
if (element) { return new Vue({
const props = JSON.parse(element.getAttribute('data')); el: element,
return new Vue({ el: root, render: elementRenderer(component, props) }); render: (createElement) => createElement(App, { props: { screen }, attrs }),
} });
}
throw new Error(__('Unknown root'));
}; };
...@@ -21,6 +21,6 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController ...@@ -21,6 +21,6 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController
end end
def feature_flag_enabled! def feature_flag_enabled!
access_denied! unless Feature.enabled?(:incubation_5mp_google_cloud) access_denied! unless Feature.enabled?(:incubation_5mp_google_cloud, project)
end end
end end
...@@ -9,10 +9,11 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud:: ...@@ -9,10 +9,11 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud::
gcp_projects = google_api_client.list_projects gcp_projects = google_api_client.list_projects
if gcp_projects.empty? if gcp_projects.empty?
@js_data = {}.to_json @js_data = { screen: 'no_gcp_projects' }.to_json
render status: :unauthorized, template: 'projects/google_cloud/errors/no_gcp_projects' render status: :unauthorized, template: 'projects/google_cloud/errors/no_gcp_projects'
else else
@js_data = { @js_data = {
screen: 'service_accounts_form',
gcpProjects: gcp_projects, gcpProjects: gcp_projects,
environments: project.environments, environments: project.environments,
cancelPath: project_google_cloud_index_path(project) cancelPath: project_google_cloud_index_path(project)
...@@ -78,7 +79,7 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud:: ...@@ -78,7 +79,7 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud::
def handle_gcp_error(error, project) def handle_gcp_error(error, project)
Gitlab::ErrorTracking.track_exception(error, project_id: project.id) Gitlab::ErrorTracking.track_exception(error, project_id: project.id)
@js_data = { error: error.to_s }.to_json @js_data = { screen: 'gcp_error', error: error.to_s }.to_json
render status: :unauthorized, template: 'projects/google_cloud/errors/gcp_error' render status: :unauthorized, template: 'projects/google_cloud/errors/gcp_error'
end end
end end
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
class Projects::GoogleCloudController < Projects::GoogleCloud::BaseController class Projects::GoogleCloudController < Projects::GoogleCloud::BaseController
def index def index
@js_data = { @js_data = {
screen: 'home',
serviceAccounts: GoogleCloud::ServiceAccountsService.new(project).find_for_project, serviceAccounts: GoogleCloud::ServiceAccountsService.new(project).find_for_project,
createServiceAccountUrl: project_google_cloud_service_accounts_path(project), createServiceAccountUrl: project_google_cloud_service_accounts_path(project),
emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg') emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg')
......
...@@ -3,4 +3,4 @@ ...@@ -3,4 +3,4 @@
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
#js-google-cloud-error-gcp-error{ data: @js_data } #js-google-cloud{ data: @js_data }
...@@ -3,4 +3,4 @@ ...@@ -3,4 +3,4 @@
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
#js-google-cloud-error-no-gcp-projects{ data: @js_data } #js-google-cloud{ data: @js_data }
...@@ -5,4 +5,4 @@ ...@@ -5,4 +5,4 @@
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
= form_tag project_google_cloud_service_accounts_path(@project), method: 'post' do = form_tag project_google_cloud_service_accounts_path(@project), method: 'post' do
#js-google-cloud-service-accounts{ data: @js_data } #js-google-cloud{ data: @js_data }
...@@ -90,7 +90,7 @@ module Sidebars ...@@ -90,7 +90,7 @@ module Sidebars
end end
def google_cloud_menu_item def google_cloud_menu_item
feature_is_enabled = Feature.enabled?(:incubation_5mp_google_cloud) feature_is_enabled = Feature.enabled?(:incubation_5mp_google_cloud, context.project)
user_has_permissions = can?(context.current_user, :admin_project_google_cloud, context.project) user_has_permissions = can?(context.current_user, :admin_project_google_cloud, context.project)
unless feature_is_enabled && user_has_permissions unless feature_is_enabled && user_has_permissions
...@@ -100,7 +100,7 @@ module Sidebars ...@@ -100,7 +100,7 @@ module Sidebars
::Sidebars::MenuItem.new( ::Sidebars::MenuItem.new(
title: _('Google Cloud'), title: _('Google Cloud'),
link: project_google_cloud_index_path(context.project), link: project_google_cloud_index_path(context.project),
active_routes: { controller: :google_cloud }, active_routes: { controller: [:google_cloud, :service_accounts] },
item_id: :google_cloud item_id: :google_cloud
) )
end end
......
...@@ -37190,7 +37190,7 @@ msgstr "" ...@@ -37190,7 +37190,7 @@ msgstr ""
msgid "Unknown response text" msgid "Unknown response text"
msgstr "" msgstr ""
msgid "Unknown root" msgid "Unknown screen"
msgstr "" msgstr ""
msgid "Unknown user" msgid "Unknown user"
......
import { shallowMount } from '@vue/test-utils';
import App from '~/google_cloud/components/app.vue';
import Home from '~/google_cloud/components/home.vue';
import IncubationBanner from '~/google_cloud/components/incubation_banner.vue';
import ServiceAccountsForm from '~/google_cloud/components/service_accounts_form.vue';
import GcpError from '~/google_cloud/components/errors/gcp_error.vue';
import NoGcpProjects from '~/google_cloud/components/errors/no_gcp_projects.vue';
const BASE_FEEDBACK_URL =
'https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/meta/-/issues/new';
describe('google_cloud App component', () => {
let wrapper;
const findIncubationBanner = () => wrapper.findComponent(IncubationBanner);
const findGcpError = () => wrapper.findComponent(GcpError);
const findNoGcpProjects = () => wrapper.findComponent(NoGcpProjects);
const findServiceAccountsForm = () => wrapper.findComponent(ServiceAccountsForm);
const findHome = () => wrapper.findComponent(Home);
afterEach(() => {
wrapper.destroy();
});
describe('for gcp_error screen', () => {
beforeEach(() => {
const propsData = {
screen: 'gcp_error',
error: 'mock_gcp_client_error',
};
wrapper = shallowMount(App, { propsData });
});
it('renders the gcp_error screen', () => {
expect(findGcpError().exists()).toBe(true);
});
it('should contain incubation banner', () => {
expect(findIncubationBanner().props()).toEqual({
shareFeedbackUrl: `${BASE_FEEDBACK_URL}?issuable_template=general_feedback`,
reportBugUrl: `${BASE_FEEDBACK_URL}?issuable_template=report_bug`,
featureRequestUrl: `${BASE_FEEDBACK_URL}?issuable_template=feature_request`,
});
});
});
describe('for no_gcp_projects screen', () => {
beforeEach(() => {
const propsData = {
screen: 'no_gcp_projects',
};
wrapper = shallowMount(App, { propsData });
});
it('renders the no_gcp_projects screen', () => {
expect(findNoGcpProjects().exists()).toBe(true);
});
it('should contain incubation banner', () => {
expect(findIncubationBanner().props()).toEqual({
shareFeedbackUrl: `${BASE_FEEDBACK_URL}?issuable_template=general_feedback`,
reportBugUrl: `${BASE_FEEDBACK_URL}?issuable_template=report_bug`,
featureRequestUrl: `${BASE_FEEDBACK_URL}?issuable_template=feature_request`,
});
});
});
describe('for service_accounts_form screen', () => {
beforeEach(() => {
const propsData = {
screen: 'service_accounts_form',
gcpProjects: [1, 2, 3],
environments: [4, 5, 6],
cancelPath: '',
};
wrapper = shallowMount(App, { propsData });
});
it('renders the service_accounts_form screen', () => {
expect(findServiceAccountsForm().exists()).toBe(true);
});
it('should contain incubation banner', () => {
expect(findIncubationBanner().props()).toEqual({
shareFeedbackUrl: `${BASE_FEEDBACK_URL}?issuable_template=general_feedback`,
reportBugUrl: `${BASE_FEEDBACK_URL}?issuable_template=report_bug`,
featureRequestUrl: `${BASE_FEEDBACK_URL}?issuable_template=feature_request`,
});
});
});
describe('for home screen', () => {
beforeEach(() => {
const propsData = {
screen: 'home',
serviceAccounts: [{}, {}],
createServiceAccountUrl: '#url-create-service-account',
emptyIllustrationUrl: '#url-empty-illustration',
};
wrapper = shallowMount(App, { propsData });
});
it('renders the home screen', () => {
expect(findHome().exists()).toBe(true);
});
it('should contain incubation banner', () => {
expect(findIncubationBanner().props()).toEqual({
shareFeedbackUrl: `${BASE_FEEDBACK_URL}?issuable_template=general_feedback`,
reportBugUrl: `${BASE_FEEDBACK_URL}?issuable_template=report_bug`,
featureRequestUrl: `${BASE_FEEDBACK_URL}?issuable_template=feature_request`,
});
});
});
});
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlTab, GlTabs } from '@gitlab/ui'; import { GlTab, GlTabs } from '@gitlab/ui';
import App from '~/google_cloud/components/screens/app.vue'; import Home from '~/google_cloud/components/home.vue';
import IncubationBanner from '~/google_cloud/components/incubation_banner.vue';
import ServiceAccountsList from '~/google_cloud/components/service_accounts_list.vue'; import ServiceAccountsList from '~/google_cloud/components/service_accounts_list.vue';
describe('google_cloud App component', () => { describe('google_cloud Home component', () => {
let wrapper; let wrapper;
const findIncubationBanner = () => wrapper.findComponent(IncubationBanner);
const findTabs = () => wrapper.findComponent(GlTabs); const findTabs = () => wrapper.findComponent(GlTabs);
const findTabItems = () => findTabs().findAllComponents(GlTab); const findTabItems = () => findTabs().findAllComponents(GlTab);
const findConfigurationTab = () => findTabItems().at(0); const findTabItemsModel = () =>
const findDeploymentTab = () => findTabItems().at(1); findTabs()
const findServicesTab = () => findTabItems().at(2); .findAllComponents(GlTab)
const findServiceAccountsList = () => findConfigurationTab().findComponent(ServiceAccountsList); .wrappers.map((x) => ({
title: x.attributes('title'),
disabled: x.attributes('disabled'),
}));
const TEST_HOME_PROPS = {
serviceAccounts: [{}, {}],
createServiceAccountUrl: '#url-create-service-account',
emptyIllustrationUrl: '#url-empty-illustration',
};
beforeEach(() => { beforeEach(() => {
const propsData = { const propsData = {
serviceAccounts: [{}, {}], screen: 'home',
createServiceAccountUrl: '#url-create-service-account', ...TEST_HOME_PROPS,
emptyIllustrationUrl: '#url-empty-illustration',
}; };
wrapper = shallowMount(App, { propsData }); wrapper = shallowMount(Home, { propsData });
}); });
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
it('should contain incubation banner', () => {
expect(findIncubationBanner().exists()).toBe(true);
});
describe('google_cloud App tabs', () => { describe('google_cloud App tabs', () => {
it('should contain tabs', () => { it('should contain tabs', () => {
expect(findTabs().exists()).toBe(true); expect(findTabs().exists()).toBe(true);
}); });
it('should contain three tab items', () => { it('should contain three tab items', () => {
expect(findTabItems().length).toBe(3); expect(findTabItemsModel()).toEqual([
{ title: 'Configuration', disabled: undefined },
{ title: 'Deployments', disabled: '' },
{ title: 'Services', disabled: '' },
]);
}); });
describe('configuration tab', () => { describe('configuration tab', () => {
it('should exist', () => {
expect(findConfigurationTab().exists()).toBe(true);
});
it('should contain service accounts component', () => { it('should contain service accounts component', () => {
expect(findServiceAccountsList().exists()).toBe(true); const serviceAccounts = findTabItems().at(0).findComponent(ServiceAccountsList);
}); expect(serviceAccounts.props()).toEqual({
}); list: TEST_HOME_PROPS.serviceAccounts,
createUrl: TEST_HOME_PROPS.createServiceAccountUrl,
describe('deployments tab', () => { emptyIllustrationUrl: TEST_HOME_PROPS.emptyIllustrationUrl,
it('should exist', () => { });
expect(findDeploymentTab().exists()).toBe(true);
});
});
describe('services tab', () => {
it('should exist', () => {
expect(findServicesTab().exists()).toBe(true);
}); });
}); });
}); });
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlButton, GlFormGroup, GlFormSelect } from '@gitlab/ui'; import { GlButton, GlFormGroup, GlFormSelect } from '@gitlab/ui';
import IncubationBanner from '~/google_cloud/components/incubation_banner.vue'; import ServiceAccountsForm from '~/google_cloud/components/service_accounts_form.vue';
import ServiceAccountsForm from '~/google_cloud/components/screens/service_accounts_form.vue';
describe('ServiceAccountsForm component', () => { describe('ServiceAccountsForm component', () => {
let wrapper; let wrapper;
const findIncubationBanner = () => wrapper.findComponent(IncubationBanner);
const findHeader = () => wrapper.find('header'); const findHeader = () => wrapper.find('header');
const findAllFormGroups = () => wrapper.findAllComponents(GlFormGroup); const findAllFormGroups = () => wrapper.findAllComponents(GlFormGroup);
const findAllFormSelects = () => wrapper.findAllComponents(GlFormSelect); const findAllFormSelects = () => wrapper.findAllComponents(GlFormSelect);
...@@ -22,10 +20,6 @@ describe('ServiceAccountsForm component', () => { ...@@ -22,10 +20,6 @@ describe('ServiceAccountsForm component', () => {
wrapper.destroy(); wrapper.destroy();
}); });
it('contains incubation banner', () => {
expect(findIncubationBanner().exists()).toBe(true);
});
it('contains header', () => { it('contains header', () => {
expect(findHeader().exists()).toBe(true); expect(findHeader().exists()).toBe(true);
}); });
......
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