Commit 7a89ded4 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch '228761-create-a-panel-metric-builder-component' into 'master'

Add panel to preview panel page

Closes #228761

See merge request gitlab-org/gitlab!37688
parents bb898f6d 5ad55f92
<script>
import { mapActions, mapState } from 'vuex';
import { GlCard, GlForm, GlFormGroup, GlFormTextarea, GlButton, GlAlert } from '@gitlab/ui';
import DashboardPanel from './dashboard_panel.vue';
const initialYml = `title:
y_label:
type: area-chart
metrics:
- query_range:
label:
`;
export default {
components: {
GlCard,
GlForm,
GlFormGroup,
GlFormTextarea,
GlButton,
GlAlert,
DashboardPanel,
},
data() {
return {
yml: initialYml,
};
},
computed: {
...mapState('monitoringDashboard', [
'panelPreviewIsLoading',
'panelPreviewError',
'panelPreviewGraphData',
]),
},
methods: {
...mapActions('monitoringDashboard', ['fetchPanelPreview']),
onSubmit() {
this.fetchPanelPreview(this.yml);
},
},
};
</script>
<template>
<div>
<gl-card>
<template #header>
<h2 class="gl-font-size-h2 gl-my-3">{{ s__('Metrics|Define and preview panel') }}</h2>
</template>
<template #default>
<gl-form @submit.prevent="onSubmit">
<gl-form-group
:label="s__('Metrics|Panel YAML')"
:description="s__('Metrics|Define panel YAML to preview panel.')"
label-for="panel-yml-input"
>
<gl-form-textarea
id="panel-yml-input"
v-model="yml"
class="gl-h-200! gl-font-monospace! gl-font-size-monospace!"
/>
</gl-form-group>
<div class="gl-text-right">
<gl-button
ref="clipboardCopyBtn"
variant="success"
category="secondary"
:data-clipboard-text="yml"
@click="$toast.show(s__('Metrics|Panel YAML copied'))"
>
{{ s__('Metrics|Copy YAML') }}
</gl-button>
<gl-button
type="submit"
variant="success"
:disabled="panelPreviewIsLoading"
class="js-no-auto-disable"
>
{{ s__('Metrics|Preview panel') }}
</gl-button>
</div>
</gl-form>
</template>
</gl-card>
<gl-alert v-if="panelPreviewError" variant="warning" :dismissible="false">
{{ panelPreviewError }}
</gl-alert>
<dashboard-panel :graph-data="panelPreviewGraphData" />
</div>
</template>
<script>
import { mapState } from 'vuex';
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import routes from '../router/constants';
import { DASHBOARD_PAGE } from '../router/constants';
import DashboardPanelBuilder from '../components/dashboard_panel_builder.vue';
export default {
components: {
GlButton,
DashboardPanelBuilder,
},
directives: {
GlTooltip: GlTooltipDirective,
},
computed: {
...mapState('monitoringDashboard', ['panelPreviewYml']),
dashboardPageLocation() {
return {
...this.$route,
name: DASHBOARD_PAGE,
};
},
},
i18n: {
backToDashboard: s__('Metrics|Back to dashboard'),
},
routes,
};
</script>
<template>
<div class="gl-display-flex gl-align-items-baseline">
<gl-button
v-gl-tooltip
icon="go-back"
:to="{ name: $options.routes.DASHBOARD_PAGE, params: { dashboard: $route.params.dashboard } }"
:aria-label="$options.i18n.backToDashboard"
:title="$options.i18n.backToDashboard"
class="gl-mr-5"
/>
<h1 class="gl-mt-5 gl-font-size-h1">{{ s__('Metrics|Add panel') }}</h1>
<!-- TODO: Add components. See https://gitlab.com/groups/gitlab-org/-/epics/2882 -->
<div class="gl-mt-5">
<div class="gl-display-flex gl-align-items-baseline gl-mb-5">
<gl-button
v-gl-tooltip
icon="go-back"
:to="dashboardPageLocation"
:aria-label="$options.i18n.backToDashboard"
:title="$options.i18n.backToDashboard"
class="gl-mr-5"
/>
<h1 class="gl-font-size-h1 gl-my-0">{{ s__('Metrics|Add panel') }}</h1>
</div>
<dashboard-panel-builder />
</div>
</template>
......@@ -41,3 +41,12 @@ export const getPrometheusQueryData = (prometheusEndpoint, params) =>
}
throw error;
});
// eslint-disable-next-line no-unused-vars
export function getPanelJson(panelPreviewEndpoint, panelPreviewYml) {
// TODO Use a real backend when it's available
// https://gitlab.com/gitlab-org/gitlab/-/issues/228758
// eslint-disable-next-line @gitlab/require-i18n-strings
return Promise.reject(new Error('API Not implemented.'));
}
......@@ -15,7 +15,7 @@ import getAnnotations from '../queries/getAnnotations.query.graphql';
import getDashboardValidationWarnings from '../queries/getDashboardValidationWarnings.query.graphql';
import { convertObjectPropsToCamelCase } from '../../lib/utils/common_utils';
import { s__, sprintf } from '../../locale';
import { getDashboard, getPrometheusQueryData } from '../requests';
import { getDashboard, getPrometheusQueryData, getPanelJson } from '../requests';
import { ENVIRONMENT_AVAILABLE_STATE, DEFAULT_DASHBOARD_PATH, VARIABLE_TYPES } from '../constants';
......@@ -473,3 +473,30 @@ export const fetchVariableMetricLabelValues = ({ state, commit }, { defaultQuery
return Promise.all(optionsRequests);
};
// Panel Builder
export const fetchPanelPreview = ({ state, commit, dispatch }, panelPreviewYml) => {
if (!panelPreviewYml) {
return null;
}
commit(types.REQUEST_PANEL_PREVIEW, panelPreviewYml);
return getPanelJson(state.panelPreviewEndpoint, panelPreviewYml)
.then(data => {
commit(types.RECEIVE_PANEL_PREVIEW_SUCCESS, data);
dispatch('fetchPanelPreviewMetrics');
})
.catch(error => {
commit(types.RECEIVE_PANEL_PREVIEW_FAILURE, error);
});
};
export const fetchPanelPreviewMetrics = () => {
// TODO Use a axios mock instead of spy when backend is implemented
// https://gitlab.com/gitlab-org/gitlab/-/issues/228758
// eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('Not implemented');
};
......@@ -46,3 +46,8 @@ export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER';
export const SET_PANEL_GROUP_METRICS = 'SET_PANEL_GROUP_METRICS';
export const SET_ENVIRONMENTS_FILTER = 'SET_ENVIRONMENTS_FILTER';
export const SET_EXPANDED_PANEL = 'SET_EXPANDED_PANEL';
// Panel preview
export const REQUEST_PANEL_PREVIEW = 'REQUEST_PANEL_PREVIEW';
export const RECEIVE_PANEL_PREVIEW_SUCCESS = 'RECEIVE_PANEL_PREVIEW_SUCCESS';
export const RECEIVE_PANEL_PREVIEW_FAILURE = 'RECEIVE_PANEL_PREVIEW_FAILURE';
import Vue from 'vue';
import { pick } from 'lodash';
import * as types from './mutation_types';
import { mapToDashboardViewModel, normalizeQueryResponseData } from './utils';
import { mapToDashboardViewModel, mapPanelToViewModel, normalizeQueryResponseData } from './utils';
import httpStatusCodes from '~/lib/utils/http_status';
import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils';
import { BACKOFF_TIMEOUT } from '~/lib/utils/common_utils';
import { dashboardEmptyStates, endpointKeys, initialStateKeys, metricStates } from '../constants';
import { optionsFromSeriesData } from './variable_mapping';
......@@ -218,4 +218,24 @@ export default {
// Add new options with assign to ensure Vue reactivity
Object.assign(variable.options, { values });
},
[types.REQUEST_PANEL_PREVIEW](state, panelPreviewYml) {
state.panelPreviewIsLoading = true;
state.panelPreviewYml = panelPreviewYml;
state.panelPreviewGraphData = null;
state.panelPreviewError = null;
},
[types.RECEIVE_PANEL_PREVIEW_SUCCESS](state, payload) {
state.panelPreviewIsLoading = false;
state.panelPreviewGraphData = mapPanelToViewModel(payload);
state.panelPreviewError = null;
},
[types.RECEIVE_PANEL_PREVIEW_FAILURE](state, error) {
state.panelPreviewIsLoading = false;
state.panelPreviewGraphData = null;
state.panelPreviewError = error;
},
};
......@@ -59,6 +59,13 @@ export default () => ({
* via the dashboard yml file.
*/
links: [],
// Panel editor / builder
panelPreviewYml: '',
panelPreviewIsLoading: false,
panelPreviewGraphData: null,
panelPreviewError: null,
// Other project data
dashboardTimezone: timezones.LOCAL,
annotations: [],
......
......@@ -82,6 +82,10 @@
.gl-h-32 { height: px-to-rem($grid-size * 4); }
.gl-h-64 { height: px-to-rem($grid-size * 8); }
// Migrate this to Gitlab UI when FF is removed
// https://gitlab.com/groups/gitlab-org/-/epics/2882
.gl-h-200\! { height: px-to-rem($grid-size * 25) !important; }
.d-sm-table-column {
@include media-breakpoint-up(sm) {
display: table-column !important;
......
......@@ -14999,6 +14999,9 @@ msgstr ""
msgid "Metrics|Check out the CI/CD documentation on deploying to an environment"
msgstr ""
msgid "Metrics|Copy YAML"
msgstr ""
msgid "Metrics|Create custom dashboard %{fileName}"
msgstr ""
......@@ -15017,6 +15020,12 @@ msgstr ""
msgid "Metrics|Current"
msgstr ""
msgid "Metrics|Define and preview panel"
msgstr ""
msgid "Metrics|Define panel YAML to preview panel."
msgstr ""
msgid "Metrics|Delete metric"
msgstr ""
......@@ -15085,6 +15094,15 @@ msgstr ""
msgid "Metrics|Open repository"
msgstr ""
msgid "Metrics|Panel YAML"
msgstr ""
msgid "Metrics|Panel YAML copied"
msgstr ""
msgid "Metrics|Preview panel"
msgstr ""
msgid "Metrics|PromQL query is valid"
msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import { GlCard, GlForm, GlFormTextarea, GlAlert } from '@gitlab/ui';
import { createStore } from '~/monitoring/stores';
import DashboardPanel from '~/monitoring/components/dashboard_panel.vue';
import * as types from '~/monitoring/stores/mutation_types';
import { metricsDashboardResponse } from '../fixture_data';
import DashboardPanelBuilder from '~/monitoring/components/dashboard_panel_builder.vue';
const mockPanel = metricsDashboardResponse.dashboard.panel_groups[0].panels[0];
describe('dashboard invalid url parameters', () => {
let store;
let wrapper;
let mockShowToast;
const createComponent = (props = {}, options = {}) => {
wrapper = shallowMount(DashboardPanelBuilder, {
propsData: { ...props },
store,
stubs: {
GlCard,
},
mocks: {
$toast: {
show: mockShowToast,
},
},
options,
});
};
const findForm = () => wrapper.find(GlForm);
const findTxtArea = () => findForm().find(GlFormTextarea);
const findSubmitBtn = () => findForm().find('[type="submit"]');
const findClipboardCopyBtn = () => wrapper.find({ ref: 'clipboardCopyBtn' });
const findPanel = () => wrapper.find(DashboardPanel);
beforeEach(() => {
mockShowToast = jest.fn();
store = createStore();
createComponent();
jest.spyOn(store, 'dispatch').mockResolvedValue();
});
afterEach(() => {});
it('is mounted', () => {
expect(wrapper.exists()).toBe(true);
});
it('displays an empty dashboard panel', () => {
expect(findPanel().exists()).toBe(true);
expect(findPanel().props('graphData')).toBe(null);
});
it('does not fetch initial data by default', () => {
expect(store.dispatch).not.toHaveBeenCalled();
});
describe('yml form', () => {
it('form exists and can be submitted', () => {
expect(findForm().exists()).toBe(true);
expect(findSubmitBtn().exists()).toBe(true);
expect(findSubmitBtn().is('[disabled]')).toBe(false);
});
it('form has a text area with a default value', () => {
expect(findTxtArea().exists()).toBe(true);
const value = findTxtArea().attributes('value');
// Panel definition should contain a title and a type
expect(value).toContain('title:');
expect(value).toContain('type:');
});
it('"copy to clipboard" button works', () => {
findClipboardCopyBtn().vm.$emit('click');
const clipboardText = findClipboardCopyBtn().attributes('data-clipboard-text');
expect(clipboardText).toContain('title:');
expect(clipboardText).toContain('type:');
expect(mockShowToast).toHaveBeenCalledTimes(1);
});
it('on submit fetches a panel preview', () => {
findForm().vm.$emit('submit', new Event('submit'));
return wrapper.vm.$nextTick().then(() => {
expect(store.dispatch).toHaveBeenCalledWith(
'monitoringDashboard/fetchPanelPreview',
expect.stringContaining('title:'),
);
});
});
describe('when form is submitted', () => {
beforeEach(() => {
store.commit(`monitoringDashboard/${types.REQUEST_PANEL_PREVIEW}`, 'mock yml content');
return wrapper.vm.$nextTick();
});
it('submit button is disabled', () => {
expect(findSubmitBtn().is('[disabled]')).toBe(true);
});
});
});
describe('when there is an error', () => {
const mockError = 'an error ocurred!';
beforeEach(() => {
store.commit(`monitoringDashboard/${types.RECEIVE_PANEL_PREVIEW_FAILURE}`, mockError);
return wrapper.vm.$nextTick();
});
it('displays an alert', () => {
expect(wrapper.find(GlAlert).exists()).toBe(true);
expect(wrapper.find(GlAlert).text()).toBe(mockError);
});
it('displays an empty dashboard panel', () => {
expect(findPanel().props('graphData')).toBe(null);
});
});
describe('when panel data is available', () => {
beforeEach(() => {
store.commit(`monitoringDashboard/${types.RECEIVE_PANEL_PREVIEW_SUCCESS}`, mockPanel);
return wrapper.vm.$nextTick();
});
it('displays no alert', () => {
expect(wrapper.find(GlAlert).exists()).toBe(false);
});
it('displays panel with data', () => {
const { title, type } = wrapper.find(DashboardPanel).props('graphData');
expect(title).toBe(mockPanel.title);
expect(type).toBe(mockPanel.type);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import { DASHBOARD_PAGE } from '~/monitoring/router/constants';
import { DASHBOARD_PAGE, PANEL_NEW_PAGE } from '~/monitoring/router/constants';
import { createStore } from '~/monitoring/stores';
import DashboardPanelBuilder from '~/monitoring/components/dashboard_panel_builder.vue';
import PanelNewPage from '~/monitoring/pages/panel_new_page.vue';
const dashboard = 'dashboard.yml';
......@@ -15,26 +18,37 @@ const GlButtonStub = {
};
describe('monitoring/pages/panel_new_page', () => {
let store;
let wrapper;
let $route;
let $router;
const mountComponent = (propsData = {}, routeParams = { dashboard }) => {
$route = {
params: routeParams,
const mountComponent = (propsData = {}, route) => {
$route = route ?? { name: PANEL_NEW_PAGE, params: { dashboard } };
$router = {
push: jest.fn(),
};
wrapper = shallowMount(PanelNewPage, {
propsData,
store,
stubs: {
GlButton: GlButtonStub,
},
mocks: {
$router,
$route,
},
});
};
const findBackButton = () => wrapper.find(GlButtonStub);
const findPanelBuilder = () => wrapper.find(DashboardPanelBuilder);
beforeEach(() => {
store = createStore();
mountComponent();
});
afterEach(() => {
wrapper.destroy();
......@@ -42,18 +56,43 @@ describe('monitoring/pages/panel_new_page', () => {
describe('back to dashboard button', () => {
it('is rendered', () => {
mountComponent();
expect(findBackButton().exists()).toBe(true);
expect(findBackButton().props('icon')).toBe('go-back');
});
it('links back to the dashboard', () => {
const dashboardLocation = {
expect(findBackButton().props('to')).toEqual({
name: DASHBOARD_PAGE,
params: { dashboard },
});
});
it('links back to the dashboard while preserving query params', () => {
$route = {
name: PANEL_NEW_PAGE,
params: { dashboard },
query: { another: 'param' },
};
expect(findBackButton().props('to')).toEqual(dashboardLocation);
mountComponent({}, $route);
expect(findBackButton().props('to')).toEqual({
name: DASHBOARD_PAGE,
params: { dashboard },
query: { another: 'param' },
});
});
});
describe('dashboard panel builder', () => {
it('is rendered', () => {
expect(findPanelBuilder().exists()).toBe(true);
});
});
describe('page routing', () => {
it('route is not updated by default', () => {
expect($router.push).not.toHaveBeenCalled();
});
});
});
......@@ -9,6 +9,7 @@ import { defaultTimeRange } from '~/vue_shared/constants';
import * as getters from '~/monitoring/stores/getters';
import { ENVIRONMENT_AVAILABLE_STATE } from '~/monitoring/constants';
import { backoffMockImplementation } from 'jest/helpers/backoff_helper';
import * as requests from '~/monitoring/requests';
import { createStore } from '~/monitoring/stores';
import * as types from '~/monitoring/stores/mutation_types';
......@@ -31,6 +32,7 @@ import {
duplicateSystemDashboard,
updateVariablesAndFetchData,
fetchVariableMetricLabelValues,
fetchPanelPreview,
} from '~/monitoring/stores/actions';
import {
gqClient,
......@@ -1154,4 +1156,56 @@ describe('Monitoring store actions', () => {
);
});
});
describe('fetchPanelPreview', () => {
const mockYmlContent = 'mock yml content';
it('should not commit or dispatch if payload is empty', () => {
testAction(fetchPanelPreview, '', state, [], []);
});
it('should store the yml content and panel in the store and fetch corresponding metrics', () => {
const mockPanel = {
title: 'title',
type: 'area-chart',
};
// TODO Use a axios mock instead of spy when backend is implemented
// https://gitlab.com/gitlab-org/gitlab/-/issues/228758
jest.spyOn(requests, 'getPanelJson').mockResolvedValue(mockPanel);
testAction(
fetchPanelPreview,
'mock yml content',
state,
[
{ type: types.REQUEST_PANEL_PREVIEW, payload: mockYmlContent },
{ type: types.RECEIVE_PANEL_PREVIEW_SUCCESS, payload: mockPanel },
],
[
{
type: 'fetchPanelPreviewMetrics',
},
],
);
});
it('should commit a failure when backend fails', () => {
const mockError = 'error';
// TODO Use a axios mock instead of spy when backend is implemented
// https://gitlab.com/gitlab-org/gitlab/-/issues/228758
jest.spyOn(requests, 'getPanelJson').mockRejectedValue(mockError);
testAction(
fetchPanelPreview,
mockYmlContent,
state,
[
{ type: types.REQUEST_PANEL_PREVIEW, payload: mockYmlContent },
{ type: types.RECEIVE_PANEL_PREVIEW_FAILURE, payload: mockError },
],
[],
);
});
});
});
......@@ -488,4 +488,42 @@ describe('Monitoring mutations', () => {
});
});
});
describe('REQUEST_PANEL_PREVIEW', () => {
it('saves yml content and resets other preview data', () => {
const mockYmlContent = 'mock yml content';
mutations[types.REQUEST_PANEL_PREVIEW](stateCopy, mockYmlContent);
expect(stateCopy.panelPreviewIsLoading).toBe(true);
expect(stateCopy.panelPreviewYml).toBe(mockYmlContent);
expect(stateCopy.panelPreviewGraphData).toBe(null);
expect(stateCopy.panelPreviewError).toBe(null);
});
});
describe('RECEIVE_PANEL_PREVIEW_SUCCESS', () => {
it('saves graph data', () => {
mutations[types.RECEIVE_PANEL_PREVIEW_SUCCESS](stateCopy, {
title: 'My Title',
type: 'area-chart',
});
expect(stateCopy.panelPreviewIsLoading).toBe(false);
expect(stateCopy.panelPreviewGraphData).toMatchObject({
title: 'My Title',
type: 'area-chart',
});
expect(stateCopy.panelPreviewError).toBe(null);
});
});
describe('RECEIVE_PANEL_PREVIEW_FAILURE', () => {
it('saves graph data', () => {
mutations[types.RECEIVE_PANEL_PREVIEW_FAILURE](stateCopy, 'Error!');
expect(stateCopy.panelPreviewIsLoading).toBe(false);
expect(stateCopy.panelPreviewGraphData).toBe(null);
expect(stateCopy.panelPreviewError).toBe('Error!');
});
});
});
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