Commit 59a34981 authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 581c10e3
...@@ -4,28 +4,22 @@ import createFlash from '~/flash'; ...@@ -4,28 +4,22 @@ import createFlash from '~/flash';
import trackDashboardLoad from '../monitoring_tracking_helper'; import trackDashboardLoad from '../monitoring_tracking_helper';
import statusCodes from '../../lib/utils/http_status'; import statusCodes from '../../lib/utils/http_status';
import { backOff } from '../../lib/utils/common_utils'; import { backOff } from '../../lib/utils/common_utils';
import { s__, __ } from '../../locale'; import { s__ } from '../../locale';
const MAX_REQUESTS = 3; const TWO_MINUTES = 120000;
export function backOffRequest(makeRequestCallback) { function backOffRequest(makeRequestCallback) {
let requestCounter = 0;
return backOff((next, stop) => { return backOff((next, stop) => {
makeRequestCallback() makeRequestCallback()
.then(resp => { .then(resp => {
if (resp.status === statusCodes.NO_CONTENT) { if (resp.status === statusCodes.NO_CONTENT) {
requestCounter += 1; next();
if (requestCounter < MAX_REQUESTS) {
next();
} else {
stop(new Error(__('Failed to connect to the prometheus server')));
}
} else { } else {
stop(resp); stop(resp);
} }
}) })
.catch(stop); .catch(stop);
}); }, TWO_MINUTES);
} }
export const setGettingStartedEmptyState = ({ commit }) => { export const setGettingStartedEmptyState = ({ commit }) => {
...@@ -52,11 +46,6 @@ export const receiveMetricsDashboardFailure = ({ commit }, error) => { ...@@ -52,11 +46,6 @@ export const receiveMetricsDashboardFailure = ({ commit }, error) => {
commit(types.RECEIVE_METRICS_DATA_FAILURE, error); commit(types.RECEIVE_METRICS_DATA_FAILURE, error);
}; };
export const requestMetricsData = ({ commit }) => commit(types.REQUEST_METRICS_DATA);
export const receiveMetricsDataSuccess = ({ commit }, data) =>
commit(types.RECEIVE_METRICS_DATA_SUCCESS, data);
export const receiveMetricsDataFailure = ({ commit }, error) =>
commit(types.RECEIVE_METRICS_DATA_FAILURE, error);
export const receiveDeploymentsDataSuccess = ({ commit }, data) => export const receiveDeploymentsDataSuccess = ({ commit }, data) =>
commit(types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS, data); commit(types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS, data);
export const receiveDeploymentsDataFailure = ({ commit }) => export const receiveDeploymentsDataFailure = ({ commit }) =>
...@@ -149,11 +138,15 @@ export const fetchPrometheusMetrics = ({ state, commit, dispatch }, params) => { ...@@ -149,11 +138,15 @@ export const fetchPrometheusMetrics = ({ state, commit, dispatch }, params) => {
}); });
}); });
return Promise.all(promises).then(() => { return Promise.all(promises)
if (state.metricsWithData.length === 0) { .then(() => {
commit(types.SET_NO_DATA_EMPTY_STATE); if (state.metricsWithData.length === 0) {
} commit(types.SET_NO_DATA_EMPTY_STATE);
}); }
})
.catch(() => {
createFlash(s__(`Metrics|There was an error while retrieving metrics`), 'warning');
});
}; };
export const fetchDeploymentsData = ({ state, dispatch }) => { export const fetchDeploymentsData = ({ state, dispatch }) => {
......
---
title: Improve the way the metrics dashboard waits for data
merge_request: 20687
author:
type: fixed
...@@ -82,12 +82,13 @@ Complete the following installation steps in order. A link at the end of each ...@@ -82,12 +82,13 @@ Complete the following installation steps in order. A link at the end of each
section will bring you back to the Scalable Architecture Examples section so section will bring you back to the Scalable Architecture Examples section so
you can continue with the next step. you can continue with the next step.
1. [Load Balancer(s)](load_balancer.md)[^2]
1. [Consul](consul.md)
1. [PostgreSQL](database.md#postgresql-in-a-scaled-environment) with [PGBouncer](https://docs.gitlab.com/ee/administration/high_availability/pgbouncer.html) 1. [PostgreSQL](database.md#postgresql-in-a-scaled-environment) with [PGBouncer](https://docs.gitlab.com/ee/administration/high_availability/pgbouncer.html)
1. [Redis](redis.md#redis-in-a-scaled-environment) 1. [Redis](redis.md#redis-in-a-scaled-environment)
1. [Gitaly](gitaly.md) (recommended) and / or [NFS](nfs.md)[^4] 1. [Gitaly](gitaly.md) (recommended) and / or [NFS](nfs.md)[^4]
1. [GitLab application nodes](gitlab.md) 1. [GitLab application nodes](gitlab.md)
- With [Object Storage service enabled](../gitaly/index.md#eliminating-nfs-altogether)[^3] - With [Object Storage service enabled](../gitaly/index.md#eliminating-nfs-altogether)[^3]
1. [Load Balancer(s)](load_balancer.md)[^2]
1. [Monitoring node (Prometheus and Grafana)](monitoring_node.md) 1. [Monitoring node (Prometheus and Grafana)](monitoring_node.md)
### Full Scaling ### Full Scaling
......
...@@ -123,6 +123,103 @@ class AddUsersLowerUsernameEmailIndexes < ActiveRecord::Migration[4.2] ...@@ -123,6 +123,103 @@ class AddUsersLowerUsernameEmailIndexes < ActiveRecord::Migration[4.2]
end end
``` ```
## Reliably referencing database columns
ActiveRecord by default returns all columns from the queried database table. In some cases the returned rows might need to be customized, for example:
- Specify only a few columns to reduce the amount of data returned from the database.
- Include columns from `JOIN` relations.
- Perform calculations (`SUM`, `COUNT`).
In this example we specify the columns, but not their tables:
- `path` from the `projects` table
- `user_id` from the `merge_requests` table
The query:
```ruby
# bad, avoid
Project.select("path, user_id").joins(:merge_requests) # SELECT path, user_id FROM "projects" ...
```
Later on, a new feature adds an extra column to the `projects` table: `user_id`. During deployment there might be a short time window where the database migration is already executed, but the new version of the application code is not deployed yet. When the query mentioned above executes during this period, the query will fail with the following error message: `PG::AmbiguousColumn: ERROR: column reference "user_id" is ambiguous`
The problem is caused by the way the attributes are selected from the database. The `user_id` column is present in both the `users` and `merge_requests` tables. The query planner cannot decide which table to use when looking up the `user_id` column.
When writing a customized `SELECT` statement, it's better to **explicitly specify the columns with the table name**.
### Good (prefer)
```ruby
Project.select(:path, 'merge_requests.user_id').joins(:merge_requests)
# SELECT "projects"."path", merge_requests.user_id as user_id FROM "projects" ...
```
```ruby
Project.select(:path, :'merge_requests.user_id').joins(:merge_requests)
# SELECT "projects"."path", "merge_requests"."id" as user_id FROM "projects" ...
```
Example using Arel (`arel_table`):
```ruby
Project.select(:path, MergeRequest.arel_table[:user_id]).joins(:merge_requests)
# SELECT "projects"."path", "merge_requests"."user_id" FROM "projects" ...
```
When writing raw SQL query:
```sql
SELECT projects.path, merge_requests.user_id FROM "projects"...
```
When the raw SQL query is parameterized (needs escaping):
```ruby
include ActiveRecord::ConnectionAdapters::Quoting
"""
SELECT
#{quote_table_name('projects')}.#{quote_column_name('path')},
#{quote_table_name('merge_requests')}.#{quote_column_name('user_id')}
FROM ...
"""
```
### Bad (avoid)
```ruby
Project.select('id, path, user_id').joins(:merge_requests).to_sql
# SELECT id, path, user_id FROM "projects" ...
```
```ruby
Project.select("path", "user_id").joins(:merge_requests)
# SELECT "projects"."path", "user_id" FROM "projects" ...
# or
Project.select(:path, :user_id).joins(:merge_requests)
# SELECT "projects"."path", "user_id" FROM "projects" ...
```
When a column list is given, ActiveRecord tries to match the arguments against the columns defined in the `projects` table and prepend the table name automatically. In this case, the `id` column is not going to be a problem, but the `user_id` column could return unexpected data:
```ruby
Project.select(:id, :user_id).joins(:merge_requests)
# Before deployment (user_id is taken from the merge_requests table):
# SELECT "projects"."id", "user_id" FROM "projects" ...
# After deployment (user_id is taken from the projects table):
# SELECT "projects"."id", "projects"."user_id" FROM "projects" ...
```
## Plucking IDs ## Plucking IDs
This can't be stressed enough: **never** use ActiveRecord's `pluck` to pluck a This can't be stressed enough: **never** use ActiveRecord's `pluck` to pluck a
......
...@@ -7181,9 +7181,6 @@ msgstr "" ...@@ -7181,9 +7181,6 @@ msgstr ""
msgid "Failed to check related branches." msgid "Failed to check related branches."
msgstr "" msgstr ""
msgid "Failed to connect to the prometheus server"
msgstr ""
msgid "Failed to create Merge Request. Please try again." msgid "Failed to create Merge Request. Please try again."
msgstr "" msgstr ""
......
...@@ -169,10 +169,12 @@ function install_external_dns() { ...@@ -169,10 +169,12 @@ function install_external_dns() {
helm repo update --tiller-namespace "${namespace}" helm repo update --tiller-namespace "${namespace}"
# Default requested: CPU => 0, memory => 0 # Default requested: CPU => 0, memory => 0
# Chart > 2.6.1 has a problem with AWS so we're pinning it for now.
# See https://gitlab.com/gitlab-org/gitlab/issues/37269 and https://github.com/kubernetes-sigs/external-dns/issues/1262
helm install stable/external-dns \ helm install stable/external-dns \
--tiller-namespace "${namespace}" \ --tiller-namespace "${namespace}" \
--namespace "${namespace}" \ --namespace "${namespace}" \
--version '^2.2.1' \ --version '2.6.1' \
--name "${release}" \ --name "${release}" \
--set provider="aws" \ --set provider="aws" \
--set aws.credentials.secretKey="${REVIEW_APPS_AWS_SECRET_KEY}" \ --set aws.credentials.secretKey="${REVIEW_APPS_AWS_SECRET_KEY}" \
......
import Vue from 'vue'; import Vue from 'vue';
import { shallowMount } from '@vue/test-utils';
import Translate from '~/vue_shared/translate'; import Translate from '~/vue_shared/translate';
import limitWarningComp from '~/cycle_analytics/components/limit_warning_component.vue'; import LimitWarningComponent from '~/cycle_analytics/components/limit_warning_component.vue';
Vue.use(Translate); Vue.use(Translate);
const createComponent = props =>
shallowMount(LimitWarningComponent, {
propsData: {
...props,
},
sync: false,
attachToDocument: true,
});
describe('Limit warning component', () => { describe('Limit warning component', () => {
let component; let component;
let LimitWarningComponent;
beforeEach(() => { beforeEach(() => {
LimitWarningComponent = Vue.extend(limitWarningComp); component = null;
});
afterEach(() => {
component.destroy();
}); });
it('should not render if count is not exactly than 50', () => { it('should not render if count is not exactly than 50', () => {
component = new LimitWarningComponent({ component = createComponent({ count: 5 });
propsData: {
count: 5,
},
}).$mount();
expect(component.$el.textContent.trim()).toBe(''); expect(component.text().trim()).toBe('');
component = new LimitWarningComponent({ component = createComponent({ count: 55 });
propsData: {
count: 55,
},
}).$mount();
expect(component.$el.textContent.trim()).toBe(''); expect(component.text().trim()).toBe('');
}); });
it('should render if count is exactly 50', () => { it('should render if count is exactly 50', () => {
component = new LimitWarningComponent({ component = createComponent({ count: 50 });
propsData: {
count: 50,
},
}).$mount();
expect(component.$el.textContent.trim()).toBe('Showing 50 events'); expect(component.text().trim()).toBe('Showing 50 events');
}); });
}); });
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import { TEST_HOST } from 'helpers/test_constants';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import statusCodes from '~/lib/utils/http_status'; import statusCodes from '~/lib/utils/http_status';
import { backOff } from '~/lib/utils/common_utils'; import { backOff } from '~/lib/utils/common_utils';
import createFlash from '~/flash';
import store from '~/monitoring/stores'; import store from '~/monitoring/stores';
import * as types from '~/monitoring/stores/mutation_types'; import * as types from '~/monitoring/stores/mutation_types';
import { import {
backOffRequest,
fetchDashboard, fetchDashboard,
receiveMetricsDashboardSuccess, receiveMetricsDashboardSuccess,
receiveMetricsDashboardFailure, receiveMetricsDashboardFailure,
...@@ -17,7 +16,6 @@ import { ...@@ -17,7 +16,6 @@ import {
fetchEnvironmentsData, fetchEnvironmentsData,
fetchPrometheusMetrics, fetchPrometheusMetrics,
fetchPrometheusMetric, fetchPrometheusMetric,
requestMetricsData,
setEndpoints, setEndpoints,
setGettingStartedEmptyState, setGettingStartedEmptyState,
} from '~/monitoring/stores/actions'; } from '~/monitoring/stores/actions';
...@@ -31,6 +29,7 @@ import { ...@@ -31,6 +29,7 @@ import {
} from '../mock_data'; } from '../mock_data';
jest.mock('~/lib/utils/common_utils'); jest.mock('~/lib/utils/common_utils');
jest.mock('~/flash');
const resetStore = str => { const resetStore = str => {
str.replaceState({ str.replaceState({
...@@ -40,71 +39,36 @@ const resetStore = str => { ...@@ -40,71 +39,36 @@ const resetStore = str => {
}); });
}; };
const MAX_REQUESTS = 3; describe('Monitoring store actions', () => {
describe('Monitoring store helpers', () => {
let mock; let mock;
// Mock underlying `backOff` function to remove in-built delay.
backOff.mockImplementation(
callback =>
new Promise((resolve, reject) => {
const stop = arg => (arg instanceof Error ? reject(arg) : resolve(arg));
const next = () => callback(next, stop);
callback(next, stop);
}),
);
beforeEach(() => { beforeEach(() => {
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('backOffRequest', () => {
it('returns immediately when recieving a 200 status code', () => {
mock.onGet(TEST_HOST).reply(200);
return backOffRequest(() => axios.get(TEST_HOST)).then(() => {
expect(mock.history.get.length).toBe(1);
});
});
it(`repeats the network call ${MAX_REQUESTS} times when receiving a 204 response`, done => { // Mock `backOff` function to remove exponential algorithm delay.
mock.onGet(TEST_HOST).reply(statusCodes.NO_CONTENT, {}); jest.useFakeTimers();
backOffRequest(() => axios.get(TEST_HOST)) backOff.mockImplementation(callback => {
.then(done.fail) const q = new Promise((resolve, reject) => {
.catch(() => { const stop = arg => (arg instanceof Error ? reject(arg) : resolve(arg));
expect(mock.history.get.length).toBe(MAX_REQUESTS); const next = () => callback(next, stop);
done(); // Define a timeout based on a mock timer
setTimeout(() => {
callback(next, stop);
}); });
});
// Run all resolved promises in chain
jest.runOnlyPendingTimers();
return q;
}); });
}); });
});
describe('Monitoring store actions', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => { afterEach(() => {
resetStore(store); resetStore(store);
mock.restore(); mock.reset();
});
describe('requestMetricsData', () => { backOff.mockReset();
it('sets emptyState to loading', () => { createFlash.mockReset();
const commit = jest.fn();
const { state } = store;
requestMetricsData({
state,
commit,
});
expect(commit).toHaveBeenCalledWith(types.REQUEST_METRICS_DATA);
});
}); });
describe('fetchDeploymentsData', () => { describe('fetchDeploymentsData', () => {
it('commits RECEIVE_DEPLOYMENTS_DATA_SUCCESS on error', done => { it('commits RECEIVE_DEPLOYMENTS_DATA_SUCCESS on error', done => {
const dispatch = jest.fn(); const dispatch = jest.fn();
...@@ -362,17 +326,11 @@ describe('Monitoring store actions', () => { ...@@ -362,17 +326,11 @@ describe('Monitoring store actions', () => {
it('commits empty state when state.groups is empty', done => { it('commits empty state when state.groups is empty', done => {
const state = storeState(); const state = storeState();
const params = {}; const params = {};
fetchPrometheusMetrics( fetchPrometheusMetrics({ state, commit, dispatch }, params)
{
state,
commit,
dispatch,
},
params,
)
.then(() => { .then(() => {
expect(commit).toHaveBeenCalledWith(types.SET_NO_DATA_EMPTY_STATE); expect(commit).toHaveBeenCalledWith(types.SET_NO_DATA_EMPTY_STATE);
expect(dispatch).not.toHaveBeenCalled(); expect(dispatch).not.toHaveBeenCalled();
expect(createFlash).not.toHaveBeenCalled();
done(); done();
}) })
.catch(done.fail); .catch(done.fail);
...@@ -382,20 +340,42 @@ describe('Monitoring store actions', () => { ...@@ -382,20 +340,42 @@ describe('Monitoring store actions', () => {
const state = storeState(); const state = storeState();
state.dashboard.panel_groups = metricsDashboardResponse.dashboard.panel_groups; state.dashboard.panel_groups = metricsDashboardResponse.dashboard.panel_groups;
const metric = state.dashboard.panel_groups[0].panels[0].metrics[0]; const metric = state.dashboard.panel_groups[0].panels[0].metrics[0];
fetchPrometheusMetrics( fetchPrometheusMetrics({ state, commit, dispatch }, params)
{ .then(() => {
state, expect(dispatch).toHaveBeenCalledTimes(3);
commit, expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', {
dispatch, metric,
}, params,
params, });
)
expect(createFlash).not.toHaveBeenCalled();
done();
})
.catch(done.fail);
done();
});
it('dispatches fetchPrometheusMetric for each panel query, handles an error', done => {
const params = {};
const state = storeState();
state.dashboard.panel_groups = metricsDashboardResponse.dashboard.panel_groups;
const metric = state.dashboard.panel_groups[0].panels[0].metrics[0];
// Mock having one out of three metrics failing
dispatch.mockRejectedValueOnce(new Error('Error fetching this metric'));
dispatch.mockResolvedValue();
fetchPrometheusMetrics({ state, commit, dispatch }, params)
.then(() => { .then(() => {
expect(dispatch).toHaveBeenCalledTimes(3); expect(dispatch).toHaveBeenCalledTimes(3);
expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', { expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', {
metric, metric,
params, params,
}); });
expect(createFlash).toHaveBeenCalledTimes(1);
done(); done();
}) })
.catch(done.fail); .catch(done.fail);
...@@ -403,28 +383,75 @@ describe('Monitoring store actions', () => { ...@@ -403,28 +383,75 @@ describe('Monitoring store actions', () => {
}); });
}); });
describe('fetchPrometheusMetric', () => { describe('fetchPrometheusMetric', () => {
it('commits prometheus query result', done => { const params = {
const commit = jest.fn(); start: '2019-08-06T12:40:02.184Z',
const params = { end: '2019-08-06T20:40:02.184Z',
start: '2019-08-06T12:40:02.184Z', };
end: '2019-08-06T20:40:02.184Z', let commit;
}; let metric;
const metric = metricsDashboardResponse.dashboard.panel_groups[0].panels[0].metrics[0]; let state;
const state = storeState(); let data;
const data = metricsGroupsAPIResponse[0].panels[0].metrics[0];
const response = { beforeEach(() => {
data, commit = jest.fn();
}; state = storeState();
mock.onGet('http://test').reply(200, response); [metric] = metricsDashboardResponse.dashboard.panel_groups[0].panels[0].metrics;
[data] = metricsGroupsAPIResponse[0].panels[0].metrics;
});
it('commits result', done => {
mock.onGet('http://test').reply(200, { data }); // One attempt
fetchPrometheusMetric({ state, commit }, { metric, params }) fetchPrometheusMetric({ state, commit }, { metric, params })
.then(() => { .then(() => {
expect(commit).toHaveBeenCalledWith(types.SET_QUERY_RESULT, { expect(commit).toHaveBeenCalledWith(types.SET_QUERY_RESULT, {
metricId: metric.metric_id, metricId: metric.metric_id,
result: data.result, result: data.result,
}); });
expect(mock.history.get).toHaveLength(1);
done(); done();
}) })
.catch(done.fail); .catch(done.fail);
}); });
it('commits result, when waiting for results', done => {
// Mock multiple attempts while the cache is filling up
mock.onGet('http://test').replyOnce(statusCodes.NO_CONTENT);
mock.onGet('http://test').replyOnce(statusCodes.NO_CONTENT);
mock.onGet('http://test').replyOnce(statusCodes.NO_CONTENT);
mock.onGet('http://test').reply(200, { data }); // 4th attempt
const fetch = fetchPrometheusMetric({ state, commit }, { metric, params });
fetch
.then(() => {
expect(commit).toHaveBeenCalledWith(types.SET_QUERY_RESULT, {
metricId: metric.metric_id,
result: data.result,
});
expect(mock.history.get).toHaveLength(4);
done();
})
.catch(done.fail);
});
it('commits failure, when waiting for results and getting a server error', done => {
// Mock multiple attempts while the cache is filling up and fails
mock.onGet('http://test').replyOnce(statusCodes.NO_CONTENT);
mock.onGet('http://test').replyOnce(statusCodes.NO_CONTENT);
mock.onGet('http://test').replyOnce(statusCodes.NO_CONTENT);
mock.onGet('http://test').reply(500); // 4th attempt
fetchPrometheusMetric({ state, commit }, { metric, params })
.then(() => {
done.fail();
})
.catch(() => {
expect(commit).not.toHaveBeenCalled();
expect(mock.history.get).toHaveLength(4);
done();
});
});
}); });
}); });
import Vue from 'vue'; import { shallowMount } from '@vue/test-utils';
import $ from 'jquery'; import $ from 'jquery';
import headerComponent from '~/vue_shared/components/markdown/header.vue'; import HeaderComponent from '~/vue_shared/components/markdown/header.vue';
import ToolbarButton from '~/vue_shared/components/markdown/toolbar_button.vue';
describe('Markdown field header component', () => { describe('Markdown field header component', () => {
let vm; let wrapper;
beforeEach(() => { const createWrapper = props => {
const Component = Vue.extend(headerComponent); wrapper = shallowMount(HeaderComponent, {
vm = new Component({
propsData: { propsData: {
previewMarkdown: false, previewMarkdown: false,
...props,
}, },
}).$mount(); sync: false,
attachToDocument: true,
});
};
const findToolbarButtons = () => wrapper.findAll(ToolbarButton);
const findToolbarButtonByProp = (prop, value) =>
findToolbarButtons()
.filter(button => button.props(prop) === value)
.at(0);
beforeEach(() => {
createWrapper();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
}); });
it('renders markdown header buttons', () => { it('renders markdown header buttons', () => {
...@@ -29,75 +46,72 @@ describe('Markdown field header component', () => { ...@@ -29,75 +46,72 @@ describe('Markdown field header component', () => {
'Add a table', 'Add a table',
'Go full screen', 'Go full screen',
]; ];
const elements = vm.$el.querySelectorAll('.toolbar-btn'); const elements = findToolbarButtons();
elements.forEach((buttonEl, index) => { elements.wrappers.forEach((buttonEl, index) => {
expect(buttonEl.getAttribute('data-original-title')).toBe(buttons[index]); expect(buttonEl.props('buttonTitle')).toBe(buttons[index]);
}); });
}); });
it('renders `write` link as active when previewMarkdown is false', () => { it('renders `write` link as active when previewMarkdown is false', () => {
expect(vm.$el.querySelector('li:nth-child(1)').classList.contains('active')).toBeTruthy(); expect(wrapper.find('li:nth-child(1)').classes()).toContain('active');
}); });
it('renders `preview` link as active when previewMarkdown is true', () => { it('renders `preview` link as active when previewMarkdown is true', () => {
vm.previewMarkdown = true; createWrapper({ previewMarkdown: true });
Vue.nextTick(() => { expect(wrapper.find('li:nth-child(2)').classes()).toContain('active');
expect(vm.$el.querySelector('li:nth-child(2)').classList.contains('active')).toBeTruthy();
});
}); });
it('emits toggle markdown event when clicking preview', () => { it('emits toggle markdown event when clicking preview', () => {
jest.spyOn(vm, '$emit').mockImplementation(); wrapper.find('.js-preview-link').trigger('click');
vm.$el.querySelector('.js-preview-link').click();
expect(vm.$emit).toHaveBeenCalledWith('preview-markdown'); expect(wrapper.emitted('preview-markdown').length).toEqual(1);
vm.$el.querySelector('.js-write-link').click(); wrapper.find('.js-write-link').trigger('click');
expect(vm.$emit).toHaveBeenCalledWith('write-markdown'); expect(wrapper.emitted('write-markdown').length).toEqual(1);
}); });
it('does not emit toggle markdown event when triggered from another form', () => { it('does not emit toggle markdown event when triggered from another form', () => {
jest.spyOn(vm, '$emit').mockImplementation();
$(document).triggerHandler('markdown-preview:show', [ $(document).triggerHandler('markdown-preview:show', [
$( $(
'<form><div class="js-vue-markdown-field"><textarea class="markdown-area"></textarea></div></form>', '<form><div class="js-vue-markdown-field"><textarea class="markdown-area"></textarea></div></form>',
), ),
]); ]);
expect(vm.$emit).not.toHaveBeenCalled(); expect(wrapper.emitted('preview-markdown')).toBeFalsy();
expect(wrapper.emitted('write-markdown')).toBeFalsy();
}); });
it('blurs preview link after click', () => { it('blurs preview link after click', () => {
const link = vm.$el.querySelector('li:nth-child(2) button'); const link = wrapper.find('li:nth-child(2) button');
jest.spyOn(HTMLElement.prototype, 'blur').mockImplementation(); jest.spyOn(HTMLElement.prototype, 'blur').mockImplementation();
link.click(); link.trigger('click');
expect(link.blur).toHaveBeenCalled(); expect(link.element.blur).toHaveBeenCalled();
}); });
it('renders markdown table template', () => { it('renders markdown table template', () => {
expect(vm.mdTable).toEqual( const tableButton = findToolbarButtonByProp('icon', 'table');
expect(tableButton.props('tag')).toEqual(
'| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |', '| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |',
); );
}); });
it('renders suggestion template', () => { it('renders suggestion template', () => {
vm.lineContent = 'Some content'; expect(findToolbarButtonByProp('buttonTitle', 'Insert suggestion').props('tag')).toEqual(
'```suggestion:-0+0\n{text}\n```',
expect(vm.mdSuggestion).toEqual('```suggestion:-0+0\n{text}\n```'); );
}); });
it('does not render suggestion button if `canSuggest` is set to false', () => { it('does not render suggestion button if `canSuggest` is set to false', () => {
vm.canSuggest = false; createWrapper({
canSuggest: false,
Vue.nextTick(() => {
expect(vm.$el.querySelector('.js-suggestion-btn')).toBe(null);
}); });
expect(wrapper.find('.js-suggestion-btn').exists()).toBe(false);
}); });
}); });
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