summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTristan Read <tread@gitlab.com>2019-01-16 15:33:49 +0100
committerJose Vargas <jvargas@gitlab.com>2019-02-07 09:19:08 -0600
commitf886c477b511dd69250cc878a8dd655e0457fa7f (patch)
tree04e258df9a194fe239a18cf2e9131a9b5e879441
parent725fcbca7a340eb49045e4205cd47832b1db3116 (diff)
downloadgitlab-ce-f886c477b511dd69250cc878a8dd655e0457fa7f.tar.gz
Frontend for error tracking settings - project dropdown
Implements the Error Tracking Settings page in Vuejs Adds functionality to request a list of projects for a given api url and token. Allows the user to save the page in any state after disabling error tracking
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue122
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/error_tracking_settings.vue44
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue104
-rw-r--r--app/assets/javascripts/error_tracking_settings/index.js53
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/actions.js84
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/index.js13
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/mutation_types.js9
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/mutations.js34
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/state.js10
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/utils.js26
-rw-r--r--app/assets/javascripts/pages/projects/settings/operations/show/index.js5
-rw-r--r--app/views/projects/settings/operations/_error_tracking.html.haml26
-rw-r--r--changelogs/unreleased/tr-error-tracking-project-selection.yml5
-rw-r--r--locale/gitlab.pot59
-rw-r--r--spec/javascripts/error_tracking_settings/components/error_tracking_form_spec.js102
-rw-r--r--spec/javascripts/error_tracking_settings/components/error_tracking_page_spec.js66
-rw-r--r--spec/javascripts/error_tracking_settings/components/project_dropdown_spec.js150
-rw-r--r--spec/javascripts/error_tracking_settings/store/actions_spec.js218
-rw-r--r--spec/javascripts/error_tracking_settings/store/mutation_spec.js33
-rw-r--r--spec/javascripts/error_tracking_settings/store/utils_spec.js74
20 files changed, 1223 insertions, 14 deletions
diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
new file mode 100644
index 00000000000..b59261086b5
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
@@ -0,0 +1,122 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: { Icon },
+ props: {
+ listProjectsEndpoint: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState({
+ showError: state => state.connectError,
+ showCheck: state => state.connectSuccessful,
+ }),
+ apiHost: {
+ get() {
+ return this.$store.state.apiHost;
+ },
+ set(apiHost) {
+ this.updateApiHost(apiHost);
+ },
+ },
+ enabled: {
+ get() {
+ return this.$store.state.enabled;
+ },
+ set(enabled) {
+ this.updateEnabled(enabled);
+ },
+ },
+ token: {
+ get() {
+ return this.$store.state.token;
+ },
+ set(token) {
+ this.updateToken(token);
+ },
+ },
+ },
+ methods: {
+ ...mapActions(['fetchProjects', 'updateApiHost', 'updateToken', 'updateEnabled']),
+ handleConnectClick() {
+ this.fetchProjects({
+ listProjectsEndpoint: this.listProjectsEndpoint,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="form-check form-group">
+ <input
+ id="error_tracking_enabled"
+ v-model="enabled"
+ class="form-check-input"
+ type="checkbox"
+ />
+ <label class="form-check-label" for="error_tracking_enabled">{{
+ s__('ErrorTracking|Active')
+ }}</label>
+ </div>
+ <div class="form-group">
+ <label class="label-bold" for="error_tracking_api_host">{{
+ s__('ErrorTracking|Sentry API URL')
+ }}</label>
+ <div class="row">
+ <div class="col-8 col-md-9 gl-pr-0">
+ <input
+ id="error_tracking_api_host"
+ v-model="apiHost"
+ class="form-control"
+ :placeholder="__('https://mysentryserver.com')"
+ />
+ </div>
+ </div>
+ <p class="form-text text-muted">
+ {{ __('Find your hostname in your Sentry account settings page') }}
+ </p>
+ </div>
+ <div class="form-group" :class="showError ? 'gl-show-field-errors' : ''">
+ <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">
+ <input
+ id="error_tracking_token"
+ v-model="token"
+ class="form-control form-control-inline gl-field-error-outline"
+ />
+ </div>
+ <div class="col-4 col-md-3 gl-pl-0">
+ <button
+ class="btn prepend-left-5"
+ data-qa-id="error_tracking_connect"
+ @click="handleConnectClick"
+ >
+ {{ s__('ErrorTracking|Connect') }}
+ </button>
+ <icon
+ v-show="showCheck"
+ class="prepend-left-5 text-success align-middle"
+ data-qa-id="error_tracking_connect_success"
+ :aria-label="__('Projects Successfully Retrieved')"
+ name="check-circle"
+ />
+ </div>
+ </div>
+ <p v-if="showError" class="gl-field-error">
+ {{ __('Connection has failed. Re-check Auth Token and try again.') }}
+ </p>
+ <p v-else class="form-text text-muted">
+ {{ __("After adding your Auth Token, use the 'Connect' button to load projects") }}
+ </p>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_settings.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_settings.vue
new file mode 100644
index 00000000000..ed372169e2c
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_settings.vue
@@ -0,0 +1,44 @@
+<script>
+import { mapActions } from 'vuex';
+import projectDropdown from './project_dropdown.vue';
+import errorTrackingForm from './error_tracking_form.vue';
+
+export default {
+ components: { projectDropdown, errorTrackingForm },
+ props: {
+ listProjectsEndpoint: {
+ type: String,
+ required: true,
+ },
+ operationsSettingsEndpoint: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ ...mapActions(['updateSettings']),
+ handleSubmit() {
+ this.updateSettings({
+ operationsSettingsEndpoint: this.operationsSettingsEndpoint,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <error-tracking-form :list-projects-endpoint="listProjectsEndpoint" />
+ <div class="form-group">
+ <project-dropdown />
+ </div>
+ <button
+ :disabled="$store.state.settingsLoading"
+ class="btn btn-success"
+ data-qa-id="error_tracking_button"
+ @click="handleSubmit"
+ >
+ {{ __('Save changes') }}
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue
new file mode 100644
index 00000000000..c5affb93c45
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue
@@ -0,0 +1,104 @@
+<script>
+import { __, s__ } from '~/locale';
+import { mapActions, mapState } from 'vuex';
+import Icon from '~/vue_shared/components/icon.vue';
+
+import { GlDropdown, GlDropdownHeader, GlDropdownItem } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownHeader,
+ GlDropdownItem,
+ Icon,
+ },
+ noAuthTokenText: __('To enable project selection, enter a valid Auth Token'),
+ noConnectionText: __(
+ "Click 'Connect' to re-establish the connection to Sentry and activate the dropdown.",
+ ),
+ noProjectsText: s__('ErrorTracking|No projects available'),
+ selectProjectText: s__('ErrorTracking|Select project'),
+ computed: {
+ ...mapState(['token', 'projects', 'selectedProject']),
+ dropdownText() {
+ if (this.selectedProject !== null) {
+ return this.getDisplayName(this.selectedProject);
+ }
+ if (!this.areProjectsLoaded || this.isProjectListEmpty) {
+ return this.$options.noProjectsText;
+ }
+ return this.$options.selectProjectText;
+ },
+ projectSelectionText() {
+ if (this.token) {
+ return this.$options.noConnectionText;
+ }
+ return this.$options.noAuthTokenText;
+ },
+ isProjectListEmpty() {
+ return this.areProjectsLoaded && this.projects.length === 0;
+ },
+ isProjectValid() {
+ return (
+ this.selectedProject &&
+ this.areProjectsLoaded &&
+ this.projects.findIndex(item => item.id === this.selectedProject.id) === -1
+ );
+ },
+ areProjectsLoaded() {
+ return this.projects !== null;
+ },
+ },
+ methods: {
+ ...mapActions(['updateSelectedProject']),
+ handleClick(event) {
+ this.updateSelectedProject({
+ ...this.projects.find(item => item.id === event.target.value),
+ });
+ },
+ getDisplayName(project) {
+ return `${project.organizationName} | ${project.name}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <div :class="[isProjectValid ? 'gl-show-field-errors' : '']">
+ <label class="label-bold" for="project_dropdown">{{ s__('ErrorTracking|Project') }}</label>
+ <div class="row">
+ <gl-dropdown
+ id="project_dropdown"
+ class="col-8 col-md-9 gl-pr-0"
+ :disabled="!areProjectsLoaded || isProjectListEmpty"
+ menu-class="w-100 mw-100"
+ toggle-class="dropdown-menu-toggle w-100 gl-field-error-outline"
+ :text="dropdownText"
+ >
+ <gl-dropdown-item
+ v-for="project in projects"
+ :key="project.id"
+ :value="project.id"
+ class="w-100"
+ @click="handleClick"
+ >{{ getDisplayName(project) }}</gl-dropdown-item
+ >
+ </gl-dropdown>
+ </div>
+ <p v-if="isProjectValid" class="gl-field-error" data-qa-id="project_dropdown_error">
+ {{
+ sprintf(
+ __('Project "%{name}" is no longer available. Select another project to continue.'),
+ { name: selectedProject.name },
+ )
+ }}
+ </p>
+ <p
+ v-else-if="!areProjectsLoaded"
+ class="form-text text-muted"
+ data-qa-id="project_dropdown_label"
+ >
+ {{ projectSelectionText }}
+ </p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/error_tracking_settings/index.js b/app/assets/javascripts/error_tracking_settings/index.js
new file mode 100644
index 00000000000..db638084cf3
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/index.js
@@ -0,0 +1,53 @@
+import Vue from 'vue';
+import store from './store';
+import ErrorTrackingSettings from './components/error_tracking_settings.vue';
+
+const getInitialProject = dataset => {
+ const {
+ projectName: name,
+ projectSlug: slug,
+ projectOrganizationName: organizationName,
+ projectOrganizationSlug: organizationSlug,
+ } = dataset;
+ if (slug) {
+ return {
+ id: organizationSlug + slug,
+ name,
+ slug,
+ organizationName,
+ organizationSlug,
+ };
+ }
+ return null;
+};
+
+export default () => {
+ const formContainerEl = document.getElementsByClassName('js-error-tracking-form')[0];
+ const {
+ dataset: { apiHost, enabled, token, listProjectsEndpoint },
+ } = formContainerEl;
+ const operationsSettingsEndpoint = formContainerEl.getAttribute('action');
+ const initialProject = getInitialProject(formContainerEl.dataset);
+
+ // Set up initial store state from DOM
+ store.dispatch('updateApiHost', apiHost);
+ store.dispatch('updateEnabled', enabled === 'false' ? false : Boolean(enabled));
+ store.dispatch('updateToken', token);
+ store.dispatch('updateSelectedProject', initialProject);
+
+ return new Vue({
+ el: formContainerEl,
+ store,
+ components: {
+ ErrorTrackingSettings,
+ },
+ render(createElement) {
+ return createElement(ErrorTrackingSettings, {
+ props: {
+ listProjectsEndpoint,
+ operationsSettingsEndpoint,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/error_tracking_settings/store/actions.js b/app/assets/javascripts/error_tracking_settings/store/actions.js
new file mode 100644
index 00000000000..f39821b56c1
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/store/actions.js
@@ -0,0 +1,84 @@
+import axios from '~/lib/utils/axios_utils';
+import * as types from './mutation_types';
+import { transformBackendProject, transformFrontendSettings } from './utils';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+
+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.RECEIVE_PROJECTS, null);
+};
+
+export const fetchProjects = ({ dispatch, state }, data) => {
+ dispatch('requestProjects');
+ return axios
+ .post(`${data.listProjectsEndpoint}.json`, {
+ error_tracking_setting: {
+ api_host: state.apiHost,
+ token: state.token,
+ },
+ })
+ .then(res => {
+ dispatch('receiveProjectsSuccess', res.data.projects.map(transformBackendProject));
+ })
+ .catch(() => {
+ dispatch('receiveProjectsError');
+ });
+};
+
+export const requestSettings = ({ commit }) => {
+ commit(types.UPDATE_SETTINGS_LOADING, true);
+};
+export const receiveSettingsSuccess = ({ commit }) => {
+ createFlash(__('Your changes have been saved.'), 'notice');
+ commit(types.UPDATE_SETTINGS_LOADING, false);
+};
+export const receiveSettingsError = (
+ { commit },
+ { response: { data: { message = '' } = {} } = {} } = {},
+) => {
+ createFlash(`${__('There was an error saving your changes.')} ${message}`, 'alert');
+ commit(types.UPDATE_SETTINGS_LOADING, false);
+};
+
+export const updateSettings = ({ dispatch, state }, data) => {
+ dispatch('requestSettings');
+ return axios
+ .patch(data.operationsSettingsEndpoint, {
+ project: {
+ error_tracking_setting_attributes: {
+ ...transformFrontendSettings(state),
+ },
+ },
+ })
+ .then(() => {
+ dispatch('receiveSettingsSuccess');
+ })
+ .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 default () => {};
diff --git a/app/assets/javascripts/error_tracking_settings/store/index.js b/app/assets/javascripts/error_tracking_settings/store/index.js
new file mode 100644
index 00000000000..a5bfb2c6d8e
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/store/index.js
@@ -0,0 +1,13 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import createState from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+Vue.use(Vuex);
+
+export default new Vuex.Store({
+ state: createState(),
+ actions,
+ mutations,
+});
diff --git a/app/assets/javascripts/error_tracking_settings/store/mutation_types.js b/app/assets/javascripts/error_tracking_settings/store/mutation_types.js
new file mode 100644
index 00000000000..ba16e90df27
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/store/mutation_types.js
@@ -0,0 +1,9 @@
+export const RECEIVE_PROJECTS = 'RECEIVE_PROJECTS';
+export const UPDATE_API_HOST = 'UPDATE_API_HOST';
+export const UPDATE_ENABLED = 'UPDATE_ENABLED';
+export const UPDATE_TOKEN = 'UPDATE_TOKEN';
+export const UPDATE_SELECTED_PROJECT = 'UPDATE_SELECTED_PROJECT';
+export const UPDATE_CONNECT_ERROR = 'UPDATE_CONNECT_ERROR';
+export const UPDATE_CONNECT_SUCCESS = 'UPDATE_CONNECT_SUCCESS';
+export const UPDATE_SETTINGS_LOADING = 'UPDATE_SETTINGS_LOADING';
+export const RESET_CONNECT = 'RESET_CONNECT';
diff --git a/app/assets/javascripts/error_tracking_settings/store/mutations.js b/app/assets/javascripts/error_tracking_settings/store/mutations.js
new file mode 100644
index 00000000000..0f73355bb35
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/store/mutations.js
@@ -0,0 +1,34 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.RECEIVE_PROJECTS](state, projects) {
+ state.projects = projects;
+ },
+ [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_CONNECT_SUCCESS](state) {
+ state.connectSuccessful = true;
+ state.connectError = false;
+ },
+ [types.UPDATE_CONNECT_ERROR](state) {
+ state.connectSuccessful = false;
+ state.connectError = true;
+ },
+ [types.RESET_CONNECT](state) {
+ state.connectSuccessful = false;
+ state.connectError = false;
+ },
+ [types.UPDATE_SETTINGS_LOADING](state, settingsLoading) {
+ state.settingsLoading = settingsLoading;
+ },
+};
diff --git a/app/assets/javascripts/error_tracking_settings/store/state.js b/app/assets/javascripts/error_tracking_settings/store/state.js
new file mode 100644
index 00000000000..e0d8856dcfa
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/store/state.js
@@ -0,0 +1,10 @@
+export default () => ({
+ apiHost: '',
+ enabled: false,
+ token: '',
+ projects: null,
+ selectedProject: null,
+ settingsLoading: false,
+ connectSuccessful: false,
+ connectError: false,
+});
diff --git a/app/assets/javascripts/error_tracking_settings/store/utils.js b/app/assets/javascripts/error_tracking_settings/store/utils.js
new file mode 100644
index 00000000000..09e4fa342a5
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/store/utils.js
@@ -0,0 +1,26 @@
+export const transformBackendProject = ({
+ slug,
+ name,
+ organization_name: organizationName,
+ organization_slug: organizationSlug,
+}) => ({
+ id: organizationSlug + slug,
+ slug,
+ name,
+ organizationName,
+ organizationSlug,
+});
+
+export const transformFrontendSettings = ({ apiHost, enabled, token, selectedProject }) => ({
+ api_host: apiHost || null,
+ enabled,
+ token: token || null,
+ project: selectedProject
+ ? {
+ slug: selectedProject.slug,
+ name: selectedProject.name,
+ organization_name: selectedProject.organizationName,
+ organization_slug: selectedProject.organizationSlug,
+ }
+ : null,
+});
diff --git a/app/assets/javascripts/pages/projects/settings/operations/show/index.js b/app/assets/javascripts/pages/projects/settings/operations/show/index.js
new file mode 100644
index 00000000000..73c745179be
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js
@@ -0,0 +1,5 @@
+import mountErrorTrackingForm from '~/error_tracking_settings';
+
+document.addEventListener('DOMContentLoaded', () => {
+ mountErrorTrackingForm();
+});
diff --git a/app/views/projects/settings/operations/_error_tracking.html.haml b/app/views/projects/settings/operations/_error_tracking.html.haml
index 4911e8d3770..5107d738029 100644
--- a/app/views/projects/settings/operations/_error_tracking.html.haml
+++ b/app/views/projects/settings/operations/_error_tracking.html.haml
@@ -8,23 +8,35 @@
= _('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'
.settings-content
- = form_for @project, url: project_settings_operations_path(@project), method: :patch do |f|
+ = form_for @project, url: project_settings_operations_path(@project), method: :patch, :html => {:class => 'edit_project js-error-tracking-form'}, data: { api_host: setting.api_host, enabled: setting.enabled, token: setting.token, project_name: setting.project_name, project_slug: setting.project_slug, project_organization_name: setting.organization_name, project_organization_slug: setting.organization_slug, list_projects_endpoint: namespace_project_list_projects_url(project_id: @project, namespace_id: @project.namespace) } 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'
+ %div
.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}/')
+ = form.label :api_host, _('Sentry API URL'), class: 'label-bold'
+ = form.url_field :api_host, class: 'form-control', placeholder: _('https://mysentryserver.com'), id: 'js-error-tracking-api-url'
%p.form-text.text-muted
- = _('Enter your Sentry API URL')
+ = _('Find your hostname in your Sentry account settings page')
.form-group
= form.label :token, _('Auth Token'), class: 'label-bold'
- = form.text_field :token, class: 'form-control'
+ = form.text_field :token, class: 'form-control', id: 'js-error-tracking-token'
%p.form-text.text-muted
- = _('Find and manage Auth Tokens in your Sentry account settings page.')
+ = _("After adding your Auth Token, use the 'Connect' button to load projects")
+ .form-group
+ %label.label-bold
+ = _('Project')
+ .dropdown
+ %button.dropdown-menu-toggle.dropdown-menu-full-width.js-dropdown-toggle{ type: 'button', disabled: true }
+ %span.dropdown-toggle-text
+ = _('No projects available')
+ = icon('chevron-down')
+ %p.form-text.text-muted
+ = _("Click 'Connect' to re-establish the connection to Sentry and activate the dropdown.")
- = f.submit _('Save changes'), class: 'btn btn-success'
+ = f.submit _('Save changes'), class: 'btn btn-success', disabled: true
diff --git a/changelogs/unreleased/tr-error-tracking-project-selection.yml b/changelogs/unreleased/tr-error-tracking-project-selection.yml
new file mode 100644
index 00000000000..ea48d0604e4
--- /dev/null
+++ b/changelogs/unreleased/tr-error-tracking-project-selection.yml
@@ -0,0 +1,5 @@
+---
+title: Error tracking - add a project selection dropdown
+merge_request: 24872
+author: tristan.read
+type: changed
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 9ec590f90d8..f858d84714d 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -543,6 +543,9 @@ msgstr ""
msgid "Advanced settings"
msgstr ""
+msgid "After adding your Auth Token, use the 'Connect' button to load projects"
+msgstr ""
+
msgid "All"
msgstr ""
@@ -1518,6 +1521,9 @@ msgstr ""
msgid "Clear search input"
msgstr ""
+msgid "Click 'Connect' to re-establish the connection to Sentry and activate the dropdown."
+msgstr ""
+
msgid "Click any <strong>project name</strong> in the project list below to navigate to the project milestone."
msgstr ""
@@ -2165,6 +2171,9 @@ msgstr ""
msgid "Connect repositories from GitHub"
msgstr ""
+msgid "Connection has failed. Re-check Auth Token and try again."
+msgstr ""
+
msgid "Container Registry"
msgstr ""
@@ -2950,9 +2959,6 @@ msgstr ""
msgid "Enter the merge request title"
msgstr ""
-msgid "Enter your Sentry API URL"
-msgstr ""
-
msgid "Environment variables"
msgstr ""
@@ -3127,6 +3133,27 @@ msgstr ""
msgid "Error:"
msgstr ""
+msgid "ErrorTracking|Active"
+msgstr ""
+
+msgid "ErrorTracking|Auth Token"
+msgstr ""
+
+msgid "ErrorTracking|Connect"
+msgstr ""
+
+msgid "ErrorTracking|No projects available"
+msgstr ""
+
+msgid "ErrorTracking|Project"
+msgstr ""
+
+msgid "ErrorTracking|Select project"
+msgstr ""
+
+msgid "ErrorTracking|Sentry API URL"
+msgstr ""
+
msgid "Errors"
msgstr ""
@@ -3328,9 +3355,6 @@ msgstr ""
msgid "Filter..."
msgstr ""
-msgid "Find and manage Auth Tokens in your Sentry account settings page."
-msgstr ""
-
msgid "Find by path"
msgstr ""
@@ -3346,6 +3370,9 @@ msgstr ""
msgid "Find the newly extracted <code>Takeout/Google Code Project Hosting/GoogleCodeProjectHosting.json</code> file."
msgstr ""
+msgid "Find your hostname in your Sentry account settings page"
+msgstr ""
+
msgid "Fingerprints"
msgstr ""
@@ -4836,6 +4863,9 @@ msgstr ""
msgid "No prioritised labels with such name or description"
msgstr ""
+msgid "No projects available"
+msgstr ""
+
msgid "No public groups"
msgstr ""
@@ -5672,6 +5702,9 @@ msgstr ""
msgid "Project"
msgstr ""
+msgid "Project \"%{name}\" is no longer available. Select another project to continue."
+msgstr ""
+
msgid "Project '%{project_name}' is in the process of being deleted."
msgstr ""
@@ -5774,6 +5807,9 @@ msgstr ""
msgid "Projects"
msgstr ""
+msgid "Projects Successfully Retrieved"
+msgstr ""
+
msgid "Projects shared with %{group_name}"
msgstr ""
@@ -7187,6 +7223,9 @@ msgstr ""
msgid "There was an error loading users activity calendar."
msgstr ""
+msgid "There was an error saving your changes."
+msgstr ""
+
msgid "There was an error saving your notification settings."
msgstr ""
@@ -7590,6 +7629,9 @@ msgstr ""
msgid "To enable it and see User Cohorts, visit %{application_settings_link_start}application settings%{application_settings_link_end}."
msgstr ""
+msgid "To enable project selection, enter a valid Auth Token"
+msgstr ""
+
msgid "To get started you enter your FogBugz URL and login information below. In the next steps, you'll be able to map users and select the projects you want to import."
msgstr ""
@@ -8433,6 +8475,9 @@ msgstr ""
msgid "Your changes have been saved"
msgstr ""
+msgid "Your changes have been saved."
+msgstr ""
+
msgid "Your comment will not be visible to the public."
msgstr ""
@@ -8542,7 +8587,7 @@ msgstr ""
msgid "here"
msgstr ""
-msgid "http://<sentry-host>/api/0/projects/{organization_slug}/{project_slug}/"
+msgid "https://mysentryserver.com"
msgstr ""
msgid "https://your-bitbucket-server"
diff --git a/spec/javascripts/error_tracking_settings/components/error_tracking_form_spec.js b/spec/javascripts/error_tracking_settings/components/error_tracking_form_spec.js
new file mode 100644
index 00000000000..6d32c54f966
--- /dev/null
+++ b/spec/javascripts/error_tracking_settings/components/error_tracking_form_spec.js
@@ -0,0 +1,102 @@
+import Vuex from 'vuex';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import ErrorTrackingForm from '~/error_tracking_settings/components/error_tracking_form.vue';
+import { TEST_HOST } from 'spec/test_constants';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('ErrorTrackingSettings', () => {
+ let store;
+ let wrapper;
+
+ function mountComponent() {
+ wrapper = shallowMount(ErrorTrackingForm, {
+ localVue,
+ store,
+ propsData: {
+ listProjectsEndpoint: TEST_HOST,
+ },
+ });
+ }
+
+ beforeEach(() => {
+ const actions = {};
+
+ const state = {
+ token: '',
+ apiHost: '',
+ connectSuccessful: false,
+ connectError: false,
+ };
+
+ store = new Vuex.Store({
+ actions,
+ state,
+ });
+
+ mountComponent();
+ });
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ }
+ });
+
+ describe('Empty form', () => {
+ it('renders the form', () => {
+ expect(wrapper.find('#error_tracking_enabled').exists()).toBeTruthy();
+ expect(wrapper.find('#error_tracking_api_host').exists()).toBeTruthy();
+ expect(wrapper.find('#error_tracking_token').exists()).toBeTruthy();
+ expect(wrapper.find('[data-qa-id=error_tracking_connect]').exists()).toBeTruthy();
+ });
+
+ it('renders labels', () => {
+ const pageText = wrapper.text();
+
+ expect(pageText).toContain('Active');
+ expect(pageText).toContain('Find your hostname in your Sentry account settings page');
+ expect(pageText).toContain(
+ "After adding your Auth Token, use the 'Connect' button to load projects",
+ );
+
+ expect(pageText).not.toContain('Connection has failed. Re-check Auth Token and try again');
+ expect(wrapper.find('#error_tracking_api_host').attributes('placeholder')).toContain(
+ 'https://mysentryserver.com',
+ );
+ });
+ });
+
+ describe('After a successful connection', () => {
+ beforeEach(() => {
+ store.state.connectSuccessful = true;
+ store.state.connectError = false;
+ });
+
+ it('shows the success checkmark', () => {
+ expect(wrapper.find('[data-qa-id=error_tracking_connect_success]').isVisible()).toBeTruthy();
+ });
+
+ it('does not show an error', () => {
+ expect(wrapper.text()).not.toContain(
+ 'Connection has failed. Re-check Auth Token and try again',
+ );
+ });
+ });
+
+ describe('After an unsuccessful connection', () => {
+ beforeEach(() => {
+ store.state.connectSuccessful = false;
+ store.state.connectError = true;
+ });
+
+ it('does not show the check mark', () => {
+ expect(wrapper.find('[data-qa-id=error_tracking_connect_success]').isVisible()).toBeFalsy();
+ });
+
+ it('shows an error', () => {
+ expect(wrapper.text()).toContain('Connection has failed. Re-check Auth Token and try again');
+ });
+ });
+});
diff --git a/spec/javascripts/error_tracking_settings/components/error_tracking_page_spec.js b/spec/javascripts/error_tracking_settings/components/error_tracking_page_spec.js
new file mode 100644
index 00000000000..a777a8c2edd
--- /dev/null
+++ b/spec/javascripts/error_tracking_settings/components/error_tracking_page_spec.js
@@ -0,0 +1,66 @@
+import Vuex from 'vuex';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import ErrorTrackingSettings from '~/error_tracking_settings/components/error_tracking_settings.vue';
+import ErrorTrackingForm from '~/error_tracking_settings/components/error_tracking_form.vue';
+import ProjectDropdown from '~/error_tracking_settings/components/project_dropdown.vue';
+import { TEST_HOST } from 'spec/test_constants';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('ErrorTrackingSettings', () => {
+ let store;
+ let wrapper;
+
+ function mountComponent() {
+ wrapper = shallowMount(ErrorTrackingSettings, {
+ localVue,
+ store,
+ propsData: {
+ listProjectsEndpoint: TEST_HOST,
+ operationsSettingsEndpoint: TEST_HOST,
+ },
+ });
+ }
+
+ beforeEach(() => {
+ const actions = {};
+ const state = {
+ settingsLoading: false,
+ };
+
+ store = new Vuex.Store({
+ actions,
+ state,
+ });
+
+ mountComponent();
+ });
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ }
+ });
+
+ describe('Section', () => {
+ it('renders the form and dropdown', () => {
+ expect(wrapper.find(ErrorTrackingForm).exists()).toBeTruthy();
+ expect(wrapper.find(ProjectDropdown).exists()).toBeTruthy();
+ });
+
+ it('renders the Save Changes button', () => {
+ expect(wrapper.find('[data-qa-id=error_tracking_button').exists()).toBeTruthy();
+ });
+
+ it('enables the button by default', () => {
+ expect(wrapper.find('[data-qa-id=error_tracking_button').attributes('disabled')).toBeFalsy();
+ });
+
+ it('disables the button when saving', () => {
+ store.state.settingsLoading = true;
+
+ expect(wrapper.find('[data-qa-id=error_tracking_button').attributes('disabled')).toBeTruthy();
+ });
+ });
+});
diff --git a/spec/javascripts/error_tracking_settings/components/project_dropdown_spec.js b/spec/javascripts/error_tracking_settings/components/project_dropdown_spec.js
new file mode 100644
index 00000000000..da960ccabac
--- /dev/null
+++ b/spec/javascripts/error_tracking_settings/components/project_dropdown_spec.js
@@ -0,0 +1,150 @@
+import _ from 'underscore';
+import Vuex from 'vuex';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import ProjectDropdown from '~/error_tracking_settings/components/project_dropdown.vue';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+const testProjects = [
+ {
+ id: 1,
+ name: 'name',
+ slug: 'slug',
+ organizationName: 'organizationName',
+ organizationSlug: 'organizationSlug',
+ },
+ {
+ id: 2,
+ name: 'name2',
+ slug: 'slug2',
+ organizationName: 'organizationName2',
+ organizationSlug: 'organizationSlug2',
+ },
+];
+
+const staleProject = {
+ id: 3,
+ name: 'staleName',
+ slug: 'staleSlug',
+ organizationName: 'staleOrganizationName',
+ organizationSlug: 'staleOrganizationSlug',
+};
+
+describe('ErrorTrackingSettings', () => {
+ let store;
+ let wrapper;
+
+ function mountComponent() {
+ wrapper = shallowMount(ProjectDropdown, {
+ localVue,
+ store,
+ });
+ }
+
+ beforeEach(() => {
+ const actions = {};
+
+ const state = {
+ token: '',
+ projects: null,
+ selectedProject: null,
+ };
+
+ store = new Vuex.Store({
+ actions,
+ state,
+ });
+
+ mountComponent();
+ });
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ }
+ });
+
+ describe('Empty project list', () => {
+ it('Renders the dropdown', () => {
+ expect(wrapper.find('#project_dropdown').exists()).toBeTruthy();
+ expect(wrapper.find(GlDropdown).exists()).toBeTruthy();
+ });
+
+ it('shows helper text', () => {
+ expect(wrapper.find('[data-qa-id=project_dropdown_label]').exists()).toBeTruthy();
+ expect(wrapper.find('[data-qa-id="project_dropdown_label"]').text()).toContain(
+ 'To enable project selection',
+ );
+ });
+
+ it('does not show an error', () => {
+ expect(wrapper.find('[data-qa-id="project_dropdown_error"]').exists()).toBeFalsy();
+ });
+
+ it('does not contain any dropdown items', () => {
+ expect(wrapper.find(GlDropdownItem).exists()).toBeFalsy();
+ // expect(wrapper.find('#project_dropdown > button').text()).toBe('No projects available');
+ });
+ });
+
+ describe('Populated project list', () => {
+ beforeEach(() => {
+ store.state.projects = _.clone(testProjects);
+ });
+
+ it('Renders the dropdown', () => {
+ expect(wrapper.find('#project_dropdown').exists()).toBeTruthy();
+ expect(wrapper.find(GlDropdown).exists()).toBeTruthy();
+ expect(wrapper.find(GlDropdown).props('text')).toContain('Select project');
+ });
+
+ it('contains a number of dropdown items', () => {
+ expect(wrapper.find(GlDropdownItem).exists()).toBeTruthy();
+ expect(wrapper.findAll(GlDropdownItem).length).toBe(2);
+ });
+ });
+
+ describe('Selected project', () => {
+ const selectedProject = _.clone(testProjects[0]);
+
+ beforeEach(() => {
+ store.state.projects = _.clone(testProjects);
+ store.state.selectedProject = selectedProject;
+ });
+
+ it('displays the selected project', () => {
+ expect(wrapper.find('#project_dropdown').props('text')).toContain(
+ selectedProject.organizationName,
+ );
+
+ expect(wrapper.find('#project_dropdown').props('text')).toContain(selectedProject.name);
+ });
+
+ it('does not show helper text', () => {
+ expect(wrapper.find('[data-qa-id=project_dropdown_label]').exists()).toBeFalsy();
+ expect(wrapper.find('[data-qa-id=project_dropdown_error]').exists()).toBeFalsy();
+ });
+ });
+
+ describe('Invalid project selected', () => {
+ beforeEach(() => {
+ store.state.projects = _.clone(testProjects);
+ store.state.selectedProject = staleProject;
+ });
+
+ it('displays the selected project', () => {
+ expect(wrapper.find('#project_dropdown').props('text')).toContain(
+ staleProject.organizationName,
+ );
+
+ expect(wrapper.find('#project_dropdown').props('text')).toContain(staleProject.name);
+ });
+
+ it('displays a error', () => {
+ expect(wrapper.find('[data-qa-id=project_dropdown_label]').exists()).toBeFalsy();
+ expect(wrapper.find('[data-qa-id=project_dropdown_error]').exists()).toBeTruthy();
+ });
+ });
+});
diff --git a/spec/javascripts/error_tracking_settings/store/actions_spec.js b/spec/javascripts/error_tracking_settings/store/actions_spec.js
new file mode 100644
index 00000000000..5e1263eb543
--- /dev/null
+++ b/spec/javascripts/error_tracking_settings/store/actions_spec.js
@@ -0,0 +1,218 @@
+import MockAdapter from 'axios-mock-adapter';
+import testAction from 'spec/helpers/vuex_action_helper';
+import * as actions from '~/error_tracking_settings/store/actions';
+import * as types from '~/error_tracking_settings/store/mutation_types';
+import axios from '~/lib/utils/axios_utils';
+import { TEST_HOST } from 'spec/test_constants';
+import { transformBackendProject } from '~/error_tracking_settings/store/utils';
+
+const projects = [
+ {
+ name: 'name',
+ slug: 'slug',
+ organization_name: 'organizationName',
+ organization_slug: 'organizationSlug',
+ },
+];
+
+describe('ErrorTrackingActions', () => {
+ let state;
+
+ describe('Project list actions', () => {
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ state = {
+ api_host: '',
+ token: '',
+ };
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('should request and transform the project list', done => {
+ mock.onPost(`${TEST_HOST}.json`).reply(() => [200, { projects }]);
+ testAction(
+ actions.fetchProjects,
+ { listProjectsEndpoint: TEST_HOST },
+ state,
+ [],
+ [
+ { type: 'requestProjects' },
+ {
+ type: 'receiveProjectsSuccess',
+ payload: projects.map(transformBackendProject),
+ },
+ ],
+ () => {
+ expect(mock.history.post.length).toBe(1);
+ done();
+ },
+ );
+ });
+
+ it('should handle a server error', done => {
+ mock.onPost(`${TEST_HOST}.json`).reply(() => [400]);
+ testAction(
+ actions.fetchProjects,
+ { listProjectsEndpoint: TEST_HOST },
+ state,
+ [],
+ [
+ { type: 'requestProjects' },
+ {
+ type: 'receiveProjectsError',
+ },
+ ],
+ () => {
+ expect(mock.history.post.length).toBe(1);
+ done();
+ },
+ );
+ });
+
+ it('should request projects correctly', done => {
+ testAction(actions.requestProjects, null, state, [{ type: types.RESET_CONNECT }], [], done);
+ });
+
+ it('should receive projects correctly', done => {
+ const testPayload = [];
+ testAction(
+ actions.receiveProjectsSuccess,
+ testPayload,
+ state,
+ [
+ { type: types.UPDATE_CONNECT_SUCCESS },
+ { type: types.RECEIVE_PROJECTS, payload: testPayload },
+ ],
+ [],
+ done,
+ );
+ });
+
+ it('should handle errors when receiving projects', done => {
+ const testPayload = [];
+ testAction(
+ actions.receiveProjectsError,
+ testPayload,
+ state,
+ [{ type: types.UPDATE_CONNECT_ERROR }, { type: types.RECEIVE_PROJECTS, payload: null }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('Save changes actions', () => {
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ state = {
+ api_host: '',
+ token: '',
+ };
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('should save the page', done => {
+ mock.onPatch(TEST_HOST).reply(200);
+ testAction(
+ actions.updateSettings,
+ { operationsSettingsEndpoint: TEST_HOST },
+ state,
+ [],
+ [{ type: 'requestSettings' }, { type: 'receiveSettingsSuccess' }],
+ () => {
+ expect(mock.history.patch.length).toBe(1);
+ done();
+ },
+ );
+ });
+
+ it('should handle a server error', done => {
+ mock.onPatch(TEST_HOST).reply(400);
+ testAction(
+ actions.updateSettings,
+ { operationsSettingsEndpoint: TEST_HOST },
+ state,
+ [],
+ [
+ { type: 'requestSettings' },
+ {
+ type: 'receiveSettingsError',
+ payload: new Error('Request failed with status code 400'),
+ },
+ ],
+ () => {
+ expect(mock.history.patch.length).toBe(1);
+ done();
+ },
+ );
+ });
+
+ it('should request to save the page', done => {
+ testAction(
+ actions.requestSettings,
+ null,
+ state,
+ [{ type: types.UPDATE_SETTINGS_LOADING, payload: true }],
+ [],
+ done,
+ );
+ });
+
+ it('should request to save the page correctly', done => {
+ testAction(
+ actions.receiveSettingsSuccess,
+ null,
+ state,
+ [{ type: types.UPDATE_SETTINGS_LOADING, payload: false }],
+ [],
+ done,
+ );
+ });
+
+ it('should handle errors when requesting to save the page', done => {
+ testAction(
+ actions.receiveSettingsError,
+ {},
+ state,
+ [{ type: types.UPDATE_SETTINGS_LOADING, payload: false }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('Generic actions to update the store', () => {
+ const testData = 'test';
+ it('should reset the `connect success` flag when updating the api host', done => {
+ testAction(
+ actions.updateApiHost,
+ testData,
+ state,
+ [{ type: types.UPDATE_API_HOST, payload: testData }, { type: types.RESET_CONNECT }],
+ [],
+ done,
+ );
+ });
+
+ it('should reset the `connect success` flag when updating the token', done => {
+ testAction(
+ actions.updateToken,
+ testData,
+ state,
+ [{ type: types.UPDATE_TOKEN, payload: testData }, { type: types.RESET_CONNECT }],
+ [],
+ done,
+ );
+ });
+ });
+});
diff --git a/spec/javascripts/error_tracking_settings/store/mutation_spec.js b/spec/javascripts/error_tracking_settings/store/mutation_spec.js
new file mode 100644
index 00000000000..1d237d5ae45
--- /dev/null
+++ b/spec/javascripts/error_tracking_settings/store/mutation_spec.js
@@ -0,0 +1,33 @@
+import mutations from '~/error_tracking_settings/store/mutations';
+import * as types from '~/error_tracking_settings/store/mutation_types';
+
+describe('ErrorTrackingSettings', () => {
+ describe('Mutations', () => {
+ let state;
+
+ beforeEach(() => {
+ state = { connectSuccessful: false, connectError: false };
+ });
+
+ it('should update state when connect is successful', () => {
+ mutations[types.UPDATE_CONNECT_SUCCESS](state);
+
+ expect(state.connectSuccessful).toBe(true);
+ expect(state.connectError).toBe(false);
+ });
+
+ it('should update state when connect fails', () => {
+ mutations[types.UPDATE_CONNECT_ERROR](state);
+
+ expect(state.connectSuccessful).toBe(false);
+ expect(state.connectError).toBe(true);
+ });
+
+ it('should update state when connect is reset', () => {
+ mutations[types.RESET_CONNECT](state);
+
+ expect(state.connectSuccessful).toBe(false);
+ expect(state.connectError).toBe(false);
+ });
+ });
+});
diff --git a/spec/javascripts/error_tracking_settings/store/utils_spec.js b/spec/javascripts/error_tracking_settings/store/utils_spec.js
new file mode 100644
index 00000000000..bbd6374b549
--- /dev/null
+++ b/spec/javascripts/error_tracking_settings/store/utils_spec.js
@@ -0,0 +1,74 @@
+import {
+ transformBackendProject,
+ transformFrontendSettings,
+} from '~/error_tracking_settings/store/utils';
+
+const normalizedProject = {
+ id: 'organization_slugslug',
+ name: 'name',
+ slug: 'slug',
+ organizationName: 'organization_name',
+ organizationSlug: 'organization_slug',
+};
+
+const sampleBackendProject = {
+ name: normalizedProject.name,
+ slug: normalizedProject.slug,
+ organization_name: normalizedProject.organizationName,
+ organization_slug: normalizedProject.organizationSlug,
+};
+
+const sampleFrontendSettings = {
+ apiHost: 'apiHost',
+ enabled: true,
+ token: 'token',
+ selectedProject: {
+ slug: normalizedProject.slug,
+ name: normalizedProject.name,
+ organizationName: normalizedProject.organizationName,
+ organizationSlug: normalizedProject.organizationSlug,
+ },
+};
+
+const transformedSettings = {
+ api_host: 'apiHost',
+ enabled: true,
+ token: 'token',
+ project: {
+ slug: normalizedProject.slug,
+ name: normalizedProject.name,
+ organization_name: normalizedProject.organizationName,
+ organization_slug: normalizedProject.organizationSlug,
+ },
+};
+
+describe('ErrorTrackingSettings', () => {
+ describe('data transform functions', () => {
+ it('should transform a backend project successfully', () => {
+ expect(transformBackendProject(sampleBackendProject)).toEqual(normalizedProject);
+ });
+
+ it('should transform settings successfully for the backend', () => {
+ expect(transformFrontendSettings(sampleFrontendSettings)).toEqual(transformedSettings);
+ });
+
+ it('should transform empty values in the settings object to null', () => {
+ const emptyFrontendSettingsObject = {
+ ...sampleFrontendSettings,
+ apiHost: '',
+ token: '',
+ selectedProject: null,
+ };
+ const transformedEmptySettingsObject = {
+ ...transformedSettings,
+ api_host: null,
+ token: null,
+ project: null,
+ };
+
+ expect(transformFrontendSettings(emptyFrontendSettingsObject)).toEqual(
+ transformedEmptySettingsObject,
+ );
+ });
+ });
+});