Skip to content
Snippets Groups Projects
Commit 43e713eb authored by Reuben Pereira's avatar Reuben Pereira Committed by Sean McGivern
Browse files

Refactor model and spec

- Move some specs into contexts
- Let get_slugs method take a parameter and return a specific slug.
- Add rescues when using Addressable::URI.
parent 4471ab81
No related branches found
No related tags found
No related merge requests found
Showing
with 710 additions and 40 deletions
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlButton } from '@gitlab/ui';
import ProjectDropdown from './project_dropdown.vue';
import ErrorTrackingForm from './error_tracking_form.vue';
export default {
components: { ProjectDropdown, ErrorTrackingForm, GlButton },
props: {
initialApiHost: {
type: String,
required: false,
default: '',
},
initialEnabled: {
type: String,
required: true,
},
initialProject: {
type: String,
required: false,
default: null,
},
initialToken: {
type: String,
required: false,
default: '',
},
listProjectsEndpoint: {
type: String,
required: true,
},
operationsSettingsEndpoint: {
type: String,
required: true,
},
},
computed: {
...mapGetters([
'dropdownLabel',
'hasProjects',
'invalidProjectLabel',
'isProjectInvalid',
'projectSelectionLabel',
]),
...mapState([
'apiHost',
'connectError',
'connectSuccessful',
'enabled',
'projects',
'selectedProject',
'settingsLoading',
'token',
]),
},
created() {
this.setInitialState({
apiHost: this.initialApiHost,
enabled: this.initialEnabled,
project: this.initialProject,
token: this.initialToken,
listProjectsEndpoint: this.listProjectsEndpoint,
operationsSettingsEndpoint: this.operationsSettingsEndpoint,
});
},
methods: {
...mapActions([
'fetchProjects',
'setInitialState',
'updateApiHost',
'updateEnabled',
'updateSelectedProject',
'updateSettings',
'updateToken',
]),
handleSubmit() {
this.updateSettings();
},
},
};
</script>
<template>
<div>
<div class="form-check form-group">
<input
id="error-tracking-enabled"
:checked="enabled"
class="form-check-input"
type="checkbox"
@change="updateEnabled($event.target.checked)"
/>
<label class="form-check-label" for="error-tracking-enabled">{{
s__('ErrorTracking|Active')
}}</label>
</div>
<error-tracking-form
:api-host="apiHost"
:connect-error="connectError"
:connect-successful="connectSuccessful"
:token="token"
@handle-connect="fetchProjects"
@update-api-host="updateApiHost"
@update-token="updateToken"
/>
<div class="form-group">
<project-dropdown
:has-projects="hasProjects"
:invalid-project-label="invalidProjectLabel"
:is-project-invalid="isProjectInvalid"
:dropdown-label="dropdownLabel"
:project-selection-label="projectSelectionLabel"
:projects="projects"
:selected-project="selectedProject"
:token="token"
@select-project="updateSelectedProject"
/>
</div>
<gl-button
:disabled="settingsLoading"
class="js-error-tracking-button"
variant="success"
@click="handleSubmit"
>
{{ __('Save changes') }}
</gl-button>
</div>
</template>
<script>
import { GlButton, GlFormInput } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: { GlButton, GlFormInput, Icon },
props: {
apiHost: {
type: String,
required: true,
},
connectError: {
type: Boolean,
required: true,
},
connectSuccessful: {
type: Boolean,
required: true,
},
token: {
type: String,
required: true,
},
},
computed: {
tokenInputState() {
return this.connectError ? false : null;
},
},
};
</script>
<template>
<div>
<div class="form-group">
<label class="label-bold" for="error-tracking-api-host">{{ __('Sentry API URL') }}</label>
<div class="row">
<div class="col-8 col-md-9 gl-pr-0">
<gl-form-input
id="error-tracking-api-host"
:value="apiHost"
placeholder="https://mysentryserver.com"
@input="$emit('update-api-host', $event)"
/>
</div>
</div>
<p class="form-text text-muted">
{{ s__('ErrorTracking|Find your hostname in your Sentry account settings page') }}
</p>
</div>
<div class="form-group" :class="{ 'gl-show-field-errors': connectError }">
<label class="label-bold" for="error-tracking-token">{{
s__('ErrorTracking|Auth Token')
}}</label>
<div class="row">
<div class="col-8 col-md-9 gl-pr-0">
<gl-form-input
id="error-tracking-token"
:value="token"
:state="tokenInputState"
@input="$emit('update-token', $event)"
/>
</div>
<div class="col-4 col-md-3 gl-pl-0">
<gl-button
class="js-error-tracking-connect prepend-left-5"
@click="$emit('handle-connect')"
>
{{ __('Connect') }}
</gl-button>
<icon
v-show="connectSuccessful"
class="js-error-tracking-connect-success prepend-left-5 text-success align-middle"
:aria-label="__('Projects Successfully Retrieved')"
name="check-circle"
/>
</div>
</div>
<p v-if="connectError" class="gl-field-error">
{{ s__('ErrorTracking|Connection has failed. Re-check Auth Token and try again.') }}
</p>
<p v-else class="form-text text-muted">
{{
s__(
"ErrorTracking|After adding your Auth Token, use the 'Connect' button to load projects",
)
}}
</p>
</div>
</div>
</template>
<script>
import { GlDropdown, GlDropdownHeader, GlDropdownItem } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import { getDisplayName } from '../utils';
export default {
components: {
GlDropdown,
GlDropdownHeader,
GlDropdownItem,
Icon,
},
props: {
dropdownLabel: {
type: String,
required: true,
},
hasProjects: {
type: Boolean,
required: true,
},
invalidProjectLabel: {
type: String,
required: true,
},
isProjectInvalid: {
type: Boolean,
required: true,
},
projects: {
type: Array,
required: true,
},
selectedProject: {
type: Object,
required: false,
default: null,
},
projectSelectionLabel: {
type: String,
required: true,
},
token: {
type: String,
required: true,
},
},
methods: {
getDisplayName,
},
};
</script>
<template>
<div :class="{ 'gl-show-field-errors': isProjectInvalid }">
<label class="label-bold" for="project-dropdown">{{ __('Project') }}</label>
<div class="row">
<gl-dropdown
id="project-dropdown"
class="col-8 col-md-9 gl-pr-0"
:disabled="!hasProjects"
menu-class="w-100 mw-100"
toggle-class="dropdown-menu-toggle w-100 gl-field-error-outline"
:text="dropdownLabel"
>
<gl-dropdown-item
v-for="project in projects"
:key="`${project.organizationSlug}.${project.slug}`"
class="w-100"
@click="$emit('select-project', project)"
>{{ getDisplayName(project) }}</gl-dropdown-item
>
</gl-dropdown>
</div>
<p v-if="isProjectInvalid" class="js-project-dropdown-error gl-field-error">
{{ invalidProjectLabel }}
</p>
<p v-else-if="!hasProjects" class="js-project-dropdown-label form-text text-muted">
{{ projectSelectionLabel }}
</p>
</div>
</template>
import Vue from 'vue';
import ErrorTrackingSettings from './components/app.vue';
import createStore from './store';
export default () => {
const formContainerEl = document.querySelector('.js-error-tracking-form');
const {
dataset: { apiHost, enabled, project, token, listProjectsEndpoint, operationsSettingsEndpoint },
} = formContainerEl;
return new Vue({
el: formContainerEl,
store: createStore(),
render(createElement) {
return createElement(ErrorTrackingSettings, {
props: {
initialApiHost: apiHost,
initialEnabled: enabled,
initialProject: project,
initialToken: token,
listProjectsEndpoint,
operationsSettingsEndpoint,
},
});
},
});
};
import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
import createFlash from '~/flash';
import { transformFrontendSettings } from '../utils';
import * as types from './mutation_types';
export const requestProjects = ({ commit }) => {
commit(types.RESET_CONNECT);
};
export const receiveProjectsSuccess = ({ commit }, projects) => {
commit(types.UPDATE_CONNECT_SUCCESS);
commit(types.RECEIVE_PROJECTS, projects);
};
export const receiveProjectsError = ({ commit }) => {
commit(types.UPDATE_CONNECT_ERROR);
commit(types.CLEAR_PROJECTS);
};
export const fetchProjects = ({ dispatch, state }) => {
dispatch('requestProjects');
return axios
.post(state.listProjectsEndpoint, {
error_tracking_setting: {
api_host: state.apiHost,
token: state.token,
},
})
.then(({ data: { projects } }) => {
dispatch('receiveProjectsSuccess', projects);
})
.catch(() => {
dispatch('receiveProjectsError');
});
};
export const requestSettings = ({ commit }) => {
commit(types.UPDATE_SETTINGS_LOADING, true);
};
export const receiveSettingsError = ({ commit }, { response = {} }) => {
const message = response.data && response.data.message ? response.data.message : '';
createFlash(`${__('There was an error saving your changes.')} ${message}`, 'alert');
commit(types.UPDATE_SETTINGS_LOADING, false);
};
export const updateSettings = ({ dispatch, state }) => {
dispatch('requestSettings');
return axios
.patch(state.operationsSettingsEndpoint, {
project: {
error_tracking_setting_attributes: {
...transformFrontendSettings(state),
},
},
})
.then(() => {
refreshCurrentPage();
})
.catch(err => {
dispatch('receiveSettingsError', err);
});
};
export const updateApiHost = ({ commit }, apiHost) => {
commit(types.UPDATE_API_HOST, apiHost);
commit(types.RESET_CONNECT);
};
export const updateEnabled = ({ commit }, enabled) => {
commit(types.UPDATE_ENABLED, enabled);
};
export const updateToken = ({ commit }, token) => {
commit(types.UPDATE_TOKEN, token);
commit(types.RESET_CONNECT);
};
export const updateSelectedProject = ({ commit }, selectedProject) => {
commit(types.UPDATE_SELECTED_PROJECT, selectedProject);
};
export const setInitialState = ({ commit }, data) => {
commit(types.SET_INITIAL_STATE, data);
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import _ from 'underscore';
import { __, s__, sprintf } from '~/locale';
import { getDisplayName } from '../utils';
export const hasProjects = state => !!state.projects && state.projects.length > 0;
export const isProjectInvalid = (state, getters) =>
!!state.selectedProject &&
getters.hasProjects &&
!state.projects.some(project => _.isMatch(state.selectedProject, project));
export const dropdownLabel = (state, getters) => {
if (state.selectedProject !== null) {
return getDisplayName(state.selectedProject);
}
if (!getters.hasProjects) {
return s__('ErrorTracking|No projects available');
}
return s__('ErrorTracking|Select project');
};
export const invalidProjectLabel = state => {
if (state.selectedProject) {
return sprintf(
__('Project "%{name}" is no longer available. Select another project to continue.'),
{
name: state.selectedProject.name,
},
);
}
return '';
};
export const projectSelectionLabel = state => {
if (state.token) {
return s__(
"ErrorTracking|Click 'Connect' to re-establish the connection to Sentry and activate the dropdown.",
);
}
return s__('ErrorTracking|To enable project selection, enter a valid Auth Token');
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import Vue from 'vue';
import Vuex from 'vuex';
import createState from './state';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
Vue.use(Vuex);
export default () =>
new Vuex.Store({
state: createState(),
actions,
getters,
mutations,
});
export const CLEAR_PROJECTS = 'CLEAR_PROJECTS';
export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
export const RECEIVE_PROJECTS = 'RECEIVE_PROJECTS';
export const RESET_CONNECT = 'RESET_CONNECT';
export const UPDATE_API_HOST = 'UPDATE_API_HOST';
export const UPDATE_CONNECT_ERROR = 'UPDATE_CONNECT_ERROR';
export const UPDATE_CONNECT_SUCCESS = 'UPDATE_CONNECT_SUCCESS';
export const UPDATE_ENABLED = 'UPDATE_ENABLED';
export const UPDATE_SELECTED_PROJECT = 'UPDATE_SELECTED_PROJECT';
export const UPDATE_SETTINGS_LOADING = 'UPDATE_SETTINGS_LOADING';
export const UPDATE_TOKEN = 'UPDATE_TOKEN';
import _ from 'underscore';
import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import * as types from './mutation_types';
import { projectKeys } from '../utils';
export default {
[types.CLEAR_PROJECTS](state) {
state.projects = [];
},
[types.RECEIVE_PROJECTS](state, projects) {
state.projects = projects
.map(convertObjectPropsToCamelCase)
// The `pick` strips out extra properties returned from Sentry.
// Such properties could be problematic later, e.g. when checking whether `projects` contains `selectedProject`
.map(project => _.pick(project, projectKeys));
},
[types.RESET_CONNECT](state) {
state.connectSuccessful = false;
state.connectError = false;
},
[types.SET_INITIAL_STATE](
state,
{ apiHost, enabled, project, token, listProjectsEndpoint, operationsSettingsEndpoint },
) {
state.enabled = parseBoolean(enabled);
state.apiHost = apiHost;
state.token = token;
state.listProjectsEndpoint = listProjectsEndpoint;
state.operationsSettingsEndpoint = operationsSettingsEndpoint;
if (project) {
state.selectedProject = _.pick(
convertObjectPropsToCamelCase(JSON.parse(project)),
projectKeys,
);
}
},
[types.UPDATE_API_HOST](state, apiHost) {
state.apiHost = apiHost;
},
[types.UPDATE_ENABLED](state, enabled) {
state.enabled = enabled;
},
[types.UPDATE_TOKEN](state, token) {
state.token = token;
},
[types.UPDATE_SELECTED_PROJECT](state, selectedProject) {
state.selectedProject = selectedProject;
},
[types.UPDATE_SETTINGS_LOADING](state, settingsLoading) {
state.settingsLoading = settingsLoading;
},
[types.UPDATE_CONNECT_SUCCESS](state) {
state.connectSuccessful = true;
state.connectError = false;
},
[types.UPDATE_CONNECT_ERROR](state) {
state.connectSuccessful = false;
state.connectError = true;
},
};
export default () => ({
apiHost: '',
enabled: false,
token: '',
projects: [],
selectedProject: null,
settingsLoading: false,
connectSuccessful: false,
connectError: false,
listProjectsEndpoint: '',
operationsSettingsEndpoint: '',
});
export const projectKeys = ['name', 'organizationName', 'organizationSlug', 'slug'];
export const transformFrontendSettings = ({ apiHost, enabled, token, selectedProject }) => {
const project = selectedProject
? {
slug: selectedProject.slug,
name: selectedProject.name,
organization_name: selectedProject.organizationName,
organization_slug: selectedProject.organizationSlug,
}
: null;
return { api_host: apiHost || null, enabled, token: token || null, project };
};
export const getDisplayName = project => `${project.organizationName} | ${project.name}`;
export default () => {};
import mountErrorTrackingForm from '~/error_tracking_settings';
document.addEventListener('DOMContentLoaded', () => {
mountErrorTrackingForm();
});
Loading
Loading
@@ -14,16 +14,37 @@ module Projects
def update
result = ::Projects::Operations::UpdateService.new(project, current_user, update_params).execute
 
render_update_response(result)
end
private
# overridden in EE
def render_update_response(result)
respond_to do |format|
format.json do
render_update_json_response(result)
end
end
end
def render_update_json_response(result)
if result[:status] == :success
flash[:notice] = _('Your changes have been saved')
redirect_to project_settings_operations_path(@project)
render json: {
status: result[:status]
}
else
render 'show'
render(
status: result[:http_status] || :bad_request,
json: {
status: result[:status],
message: result[:message]
}
)
end
end
 
private
def error_tracking_setting
@error_tracking_setting ||= project.error_tracking_setting ||
project.build_error_tracking_setting
Loading
Loading
@@ -35,7 +56,14 @@ module Projects
 
# overridden in EE
def permitted_project_params
{ error_tracking_setting_attributes: [:enabled, :api_url, :token] }
{
error_tracking_setting_attributes: [
:enabled,
:api_host,
:token,
project: [:slug, :name, :organization_slug, :organization_name]
]
}
end
 
def check_license
Loading
Loading
Loading
Loading
@@ -284,6 +284,20 @@ module ProjectsHelper
can?(current_user, :read_environment, @project)
end
 
def error_tracking_setting_project_json
setting = @project.error_tracking_setting
return if setting.blank? || setting.project_slug.blank? ||
setting.organization_slug.blank?
{
name: setting.project_name,
organization_name: setting.organization_name,
organization_slug: setting.organization_slug,
slug: setting.project_slug
}.to_json
end
private
 
def get_project_nav_tabs(project, current_user)
Loading
Loading
Loading
Loading
@@ -2,19 +2,30 @@
 
module ErrorTracking
class ProjectErrorTrackingSetting < ActiveRecord::Base
include Gitlab::Utils::StrongMemoize
include ReactiveCaching
 
API_URL_PATH_REGEXP = %r{
\A
(?<prefix>/api/0/projects/+)
(?:
(?<organization>[^/]+)/+
(?<project>[^/]+)/*
)?
\z
}x
self.reactive_cache_key = ->(setting) { [setting.class.model_name.singular, setting.project_id] }
 
belongs_to :project
 
validates :api_url, length: { maximum: 255 }, public_url: true, url: { enforce_sanitization: true, ascii_only: true }, allow_nil: true
 
validates :api_url, presence: true, if: :enabled
validates :api_url, presence: { message: 'is a required field' }, if: :enabled
 
validate :validate_api_url_path, if: :enabled
 
validates :token, presence: true, if: :enabled
validates :token, presence: { message: 'is a required field' }, if: :enabled
 
attr_encrypted :token,
mode: :per_attribute_iv,
Loading
Loading
@@ -23,6 +34,11 @@ module ErrorTracking
 
after_save :clear_reactive_cache!
 
def api_url=(value)
super
clear_memoization(:api_url_slugs)
end
def project_name
super || project_name_from_slug
end
Loading
Loading
@@ -40,6 +56,8 @@ module ErrorTracking
end
 
def self.build_api_url_from(api_host:, project_slug:, organization_slug:)
return if api_host.blank?
uri = Addressable::URI.parse("#{api_host}/api/0/projects/#{organization_slug}/#{project_slug}/")
uri.path = uri.path.squeeze('/')
 
Loading
Loading
@@ -100,34 +118,39 @@ module ErrorTracking
end
 
def project_slug_from_api_url
extract_slug(:project)
api_url_slug(:project)
end
 
def organization_slug_from_api_url
extract_slug(:organization)
api_url_slug(:organization)
end
def api_url_slug(capture)
slugs = strong_memoize(:api_url_slugs) { extract_api_url_slugs || {} }
slugs[capture]
end
 
def extract_slug(capture)
def extract_api_url_slugs
return if api_url.blank?
 
begin
url = Addressable::URI.parse(api_url)
rescue Addressable::URI::InvalidURIError
return nil
return
end
 
@slug_match ||= url.path.match(%r{^/api/0/projects/+(?<organization>[^/]+)/+(?<project>[^/|$]+)}) || {}
@slug_match[capture]
url.path.match(API_URL_PATH_REGEXP)
end
 
def validate_api_url_path
return if api_url.blank?
 
begin
unless Addressable::URI.parse(api_url).path.starts_with?('/api/0/projects')
errors.add(:api_url, 'path needs to start with /api/0/projects')
end
rescue Addressable::URI::InvalidURIError
unless api_url_slug(:prefix)
return errors.add(:api_url, 'is invalid')
end
unless api_url_slug(:organization)
errors.add(:project, 'is a required field')
end
end
end
Loading
Loading
Loading
Loading
@@ -28,8 +28,8 @@ module ErrorTracking
(project.error_tracking_setting || project.build_error_tracking_setting).tap do |setting|
setting.api_url = ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from(
api_host: params[:api_host],
organization_slug: nil,
project_slug: nil
organization_slug: 'org',
project_slug: 'proj'
)
 
setting.token = params[:token]
Loading
Loading
Loading
Loading
@@ -12,7 +12,28 @@ module Projects
private
 
def project_update_params
params.slice(:error_tracking_setting_attributes)
error_tracking_params
end
def error_tracking_params
settings = params[:error_tracking_setting_attributes]
return {} if settings.blank?
api_url = ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from(
api_host: settings[:api_host],
project_slug: settings.dig(:project, :slug),
organization_slug: settings.dig(:project, :organization_slug)
)
{
error_tracking_setting_attributes: {
api_url: api_url,
token: settings[:token],
enabled: settings[:enabled],
project_name: settings.dig(:project, :name),
organization_name: settings.dig(:project, :organization_name)
}
}
end
end
end
Loading
Loading
Loading
Loading
@@ -8,23 +8,11 @@
= _('Error Tracking')
%p
= _('To link Sentry to GitLab, enter your Sentry URL and Auth Token.')
= link_to _('More information'), help_page_path('user/project/operations/error_tracking'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= form_for @project, url: project_settings_operations_path(@project), method: :patch do |f|
= form_errors(@project)
.form-group
= f.fields_for :error_tracking_setting_attributes, setting do |form|
.form-check.form-group
= form.check_box :enabled, class: 'form-check-input'
= form.label :enabled, _('Active'), class: 'form-check-label'
.form-group
= form.label :api_url, _('Sentry API URL'), class: 'label-bold'
= form.url_field :api_url, class: 'form-control', placeholder: _('http://<sentry-host>/api/0/projects/{organization_slug}/{project_slug}/')
%p.form-text.text-muted
= _('Enter your Sentry API URL')
.form-group
= form.label :token, _('Auth Token'), class: 'label-bold'
= form.text_field :token, class: 'form-control'
%p.form-text.text-muted
= _('Find and manage Auth Tokens in your Sentry account settings page.')
= f.submit _('Save changes'), class: 'btn btn-success'
.js-error-tracking-form{ data: { list_projects_endpoint: list_projects_project_error_tracking_index_path(@project, format: :json),
operations_settings_endpoint: project_settings_operations_path(@project),
project: error_tracking_setting_project_json,
api_host: setting.api_host,
enabled: setting.enabled.to_json,
token: setting.token } }
---
title: Error tracking configuration - add a Sentry project selection dropdown
merge_request: 24701
author:
type: changed
Loading
Loading
@@ -10,6 +10,10 @@ en:
target: Target issue
group:
path: Group URL
project/error_tracking_setting:
token: "Auth Token"
project: "Project"
api_url: "Sentry API URL"
errors:
messages:
label_already_exists_at_group_level: "already exists at group level for %{group}. Please choose another one."
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment