Commit 36fec6a0 authored by Douglas Barbosa Alexandre's avatar Douglas Barbosa Alexandre

Merge branch...

Merge branch '292747-in-product-guidance-for-deployments-for-users-that-are-not-deploying-webide' into 'master'

Show an Alert that a user is not using deployments [RUN ALL RSPEC] [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!57160
parents 75286ed4 868a9a67
mutation dismissUserCallout($input: UserCalloutCreateInput!) {
userCalloutCreate(input: $input) {
errors
userCallout {
dismissedAt
featureName
}
}
}
<script>
import { GlAlert } from '@gitlab/ui';
import { getAlert } from '../lib/alerts';
export default {
components: {
GlAlert,
},
props: {
alertKey: {
type: Symbol,
required: true,
},
},
computed: {
alert() {
return getAlert(this.alertKey);
},
},
};
</script>
<template>
<gl-alert v-bind="alert.props" @dismiss="alert.dismiss($store)">
<component :is="alert.message" />
</gl-alert>
</template>
<script>
import { debounce } from 'lodash';
import { mapState, mapGetters, mapActions } from 'vuex';
import {
EDITOR_TYPE_DIFF,
......@@ -34,11 +35,13 @@ import mapRulesToMonaco from '../lib/editorconfig/rules_mapper';
import { getFileEditorOrDefault } from '../stores/modules/editor/utils';
import { extractMarkdownImagesFromEntries } from '../stores/utils';
import { getPathParent, readFileAsDataURL, registerSchema, isTextFile } from '../utils';
import FileAlert from './file_alert.vue';
import FileTemplatesBar from './file_templates/bar.vue';
export default {
name: 'RepoEditor',
components: {
FileAlert,
ContentViewer,
DiffViewer,
FileTemplatesBar,
......@@ -57,6 +60,7 @@ export default {
globalEditor: null,
modelManager: new ModelManager(),
isEditorLoading: true,
unwatchCiYaml: null,
};
},
computed: {
......@@ -74,6 +78,7 @@ export default {
'currentProjectId',
]),
...mapGetters([
'getAlert',
'currentMergeRequest',
'getStagedFile',
'isEditModeActive',
......@@ -82,6 +87,9 @@ export default {
'getJsonSchemaForPath',
]),
...mapGetters('fileTemplates', ['showFileTemplatesBar']),
alertKey() {
return this.getAlert(this.file);
},
fileEditor() {
return getFileEditorOrDefault(this.fileEditors, this.file.path);
},
......@@ -136,6 +144,16 @@ export default {
},
},
watch: {
'file.name': {
handler() {
this.stopWatchingCiYaml();
if (this.file.name === '.gitlab-ci.yml') {
this.startWatchingCiYaml();
}
},
immediate: true,
},
file(newVal, oldVal) {
if (oldVal.pending) {
this.removePendingTab(oldVal);
......@@ -216,6 +234,7 @@ export default {
'removePendingTab',
'triggerFilesChange',
'addTempImage',
'detectGitlabCiFileAlerts',
]),
...mapActions('editor', ['updateFileEditor']),
initEditor() {
......@@ -422,6 +441,18 @@ export default {
this.updateFileEditor({ path: this.file.path, data });
},
startWatchingCiYaml() {
this.unwatchCiYaml = this.$watch(
'file.content',
debounce(this.detectGitlabCiFileAlerts, 500),
);
},
stopWatchingCiYaml() {
if (this.unwatchCiYaml) {
this.unwatchCiYaml();
this.unwatchCiYaml = null;
}
},
},
viewerTypes,
FILE_VIEW_MODE_EDITOR,
......@@ -439,9 +470,8 @@ export default {
role="button"
data-testid="edit-tab"
@click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })"
>{{ __('Edit') }}</a
>
{{ __('Edit') }}
</a>
</li>
<li v-if="previewMode" :class="previewTabCSS">
<a
......@@ -454,7 +484,8 @@ export default {
</li>
</ul>
</div>
<file-templates-bar v-if="showFileTemplatesBar(file.name)" />
<file-alert v-if="alertKey" :alert-key="alertKey" />
<file-templates-bar v-else-if="showFileTemplatesBar(file.name)" />
<div
v-show="showEditor"
ref="editor"
......
......@@ -56,11 +56,12 @@ export function initIde(el, options = {}) {
webIDEHelpPagePath: el.dataset.webIdeHelpPagePath,
forkInfo: el.dataset.forkInfo ? JSON.parse(el.dataset.forkInfo) : null,
});
this.setInitialData({
this.init({
clientsidePreviewEnabled: parseBoolean(el.dataset.clientsidePreviewEnabled),
renderWhitespaceInCode: parseBoolean(el.dataset.renderWhitespaceInCode),
editorTheme: window.gon?.user_color_scheme || DEFAULT_THEME,
codesandboxBundlerUrl: el.dataset.codesandboxBundlerUrl,
environmentsGuidanceAlertDismissed: !parseBoolean(el.dataset.enableEnvironmentsGuidance),
});
},
beforeDestroy() {
......@@ -68,7 +69,7 @@ export function initIde(el, options = {}) {
this.$emit('destroy');
},
methods: {
...mapActions(['setEmptyStateSvgs', 'setLinks', 'setInitialData']),
...mapActions(['setEmptyStateSvgs', 'setLinks', 'init']),
},
render(createElement) {
return createElement(rootComponent);
......
<script>
import { GlSprintf, GlLink } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
export default {
components: { GlSprintf, GlLink },
message: __(
"No deployments detected. Use environments to control your software's continuous deployment. %{linkStart}Learn more about deployment jobs.%{linkEnd}",
),
computed: {
helpLink() {
return helpPagePath('ci/environments/index.md');
},
},
};
</script>
<template>
<span>
<gl-sprintf :message="$options.message">
<template #link="{ content }">
<gl-link
:href="helpLink"
target="_blank"
data-track-action="click_link"
data-track-experiment="in_product_guidance_environments_webide"
>{{ content }}</gl-link
>
</template>
</gl-sprintf>
</span>
</template>
import { leftSidebarViews } from '../../constants';
import EnvironmentsMessage from './environments.vue';
const alerts = [
{
key: Symbol('ALERT_ENVIRONMENT'),
show: (state, file) =>
state.currentActivityView === leftSidebarViews.commit.name &&
file.path === '.gitlab-ci.yml' &&
state.environmentsGuidanceAlertDetected &&
!state.environmentsGuidanceAlertDismissed,
props: { variant: 'tip' },
dismiss: ({ dispatch }) => dispatch('dismissEnvironmentsGuidance'),
message: EnvironmentsMessage,
},
];
export const findAlertKeyToShow = (...args) => alerts.find((x) => x.show(...args))?.key;
export const getAlert = (key) => alerts.find((x) => x.key === key);
......@@ -18,3 +18,4 @@ const getClient = memoize(() =>
);
export const query = (...args) => getClient().query(...args);
export const mutate = (...args) => getClient().mutate(...args);
import getIdeProject from 'ee_else_ce/ide/queries/get_ide_project.query.graphql';
import Api from '~/api';
import dismissUserCallout from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql';
import axios from '~/lib/utils/axios_utils';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
import { query } from './gql';
import ciConfig from '~/pipeline_editor/graphql/queries/ci_config.graphql';
import { query, mutate } from './gql';
const fetchApiProjectData = (projectPath) => Api.project(projectPath).then(({ data }) => data);
......@@ -101,4 +103,16 @@ export default {
const url = `${gon.relative_url_root}/${projectPath}/usage_ping/web_ide_pipelines_count`;
return axios.post(url);
},
getCiConfig(projectPath, content) {
return query({
query: ciConfig,
variables: { projectPath, content },
}).then(({ data }) => data.ciConfig);
},
dismissUserCallout(name) {
return mutate({
mutation: dismissUserCallout,
variables: { input: { featureName: name } },
}).then(({ data }) => data);
},
};
......@@ -17,7 +17,7 @@ import * as types from './mutation_types';
export const redirectToUrl = (self, url) => visitUrl(url);
export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
export const init = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
export const discardAllChanges = ({ state, commit, dispatch }) => {
state.changedFiles.forEach((file) => dispatch('restoreOriginalFile', file.path));
......@@ -316,3 +316,4 @@ export * from './actions/tree';
export * from './actions/file';
export * from './actions/project';
export * from './actions/merge_request';
export * from './actions/alert';
import service from '../../services';
import {
DETECT_ENVIRONMENTS_GUIDANCE_ALERT,
DISMISS_ENVIRONMENTS_GUIDANCE_ALERT,
} from '../mutation_types';
export const detectGitlabCiFileAlerts = ({ dispatch }, content) =>
dispatch('detectEnvironmentsGuidance', content);
export const detectEnvironmentsGuidance = ({ commit, state }, content) =>
service.getCiConfig(state.currentProjectId, content).then((data) => {
commit(DETECT_ENVIRONMENTS_GUIDANCE_ALERT, data?.stages);
});
export const dismissEnvironmentsGuidance = ({ commit }) =>
service.dismissUserCallout('web_ide_ci_environments_guidance').then(() => {
commit(DISMISS_ENVIRONMENTS_GUIDANCE_ALERT);
});
......@@ -262,3 +262,5 @@ export const getJsonSchemaForPath = (state, getters) => (path) => {
fileMatch: [`*${path}`],
};
};
export * from './getters/alert';
import { findAlertKeyToShow } from '../../lib/alerts';
export const getAlert = (state) => (file) => findAlertKeyToShow(state, file);
......@@ -70,3 +70,8 @@ export const RENAME_ENTRY = 'RENAME_ENTRY';
export const REVERT_RENAME_ENTRY = 'REVERT_RENAME_ENTRY';
export const RESTORE_TREE = 'RESTORE_TREE';
// Alert mutation types
export const DETECT_ENVIRONMENTS_GUIDANCE_ALERT = 'DETECT_ENVIRONMENTS_GUIDANCE_ALERT';
export const DISMISS_ENVIRONMENTS_GUIDANCE_ALERT = 'DISMISS_ENVIRONMENTS_GUIDANCE_ALERT';
import Vue from 'vue';
import * as types from './mutation_types';
import alertMutations from './mutations/alert';
import branchMutations from './mutations/branch';
import fileMutations from './mutations/file';
import mergeRequestMutation from './mutations/merge_request';
......@@ -244,4 +245,5 @@ export default {
...fileMutations,
...treeMutations,
...branchMutations,
...alertMutations,
};
import {
DETECT_ENVIRONMENTS_GUIDANCE_ALERT,
DISMISS_ENVIRONMENTS_GUIDANCE_ALERT,
} from '../mutation_types';
export default {
[DETECT_ENVIRONMENTS_GUIDANCE_ALERT](state, stages) {
if (!stages) {
return;
}
const hasEnvironments = stages?.nodes?.some((stage) =>
stage.groups.nodes.some((group) => group.jobs.nodes.some((job) => job.environment)),
);
const hasParsedCi = Array.isArray(stages.nodes);
state.environmentsGuidanceAlertDetected = !hasEnvironments && hasParsedCi;
},
[DISMISS_ENVIRONMENTS_GUIDANCE_ALERT](state) {
state.environmentsGuidanceAlertDismissed = true;
},
};
......@@ -30,4 +30,6 @@ export default () => ({
renderWhitespaceInCode: false,
editorTheme: DEFAULT_THEME,
codesandboxBundlerUrl: null,
environmentsGuidanceAlertDismissed: false,
environmentsGuidanceAlertDetected: false,
});
# frozen_string_literal: true
class InProductGuidanceEnvironmentsWebideExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
exclude :has_environments?
def control_behavior
false
end
private
def has_environments?
!context.project.environments.empty?
end
end
......@@ -17,7 +17,8 @@ module IdeHelper
'file-path' => @path,
'merge-request' => @merge_request,
'fork-info' => @fork_info&.to_json,
'project' => convert_to_project_entity_json(@project)
'project' => convert_to_project_entity_json(@project),
'enable-environments-guidance' => enable_environments_guidance?.to_s
}
end
......@@ -28,6 +29,18 @@ module IdeHelper
API::Entities::Project.represent(project).to_json
end
def enable_environments_guidance?
experiment(:in_product_guidance_environments_webide, project: @project) do |e|
e.try { !has_dismissed_ide_environments_callout? }
e.run
end
end
def has_dismissed_ide_environments_callout?
current_user.dismissed_callout?(feature_name: 'web_ide_ci_environments_guidance')
end
end
::IdeHelper.prepend_if_ee('::EE::IdeHelper')
......@@ -31,7 +31,8 @@ class UserCallout < ApplicationRecord
unfinished_tag_cleanup_callout: 27,
eoa_bronze_plan_banner: 28, # EE-only
pipeline_needs_banner: 29,
pipeline_needs_hover_tip: 30
pipeline_needs_hover_tip: 30,
web_ide_ci_environments_guidance: 31
}
validates :user, presence: true
......
---
name: in_product_guidance_environments_webide
introduced_by_url:
rollout_issue_url:
milestone: '13.12'
type: experiment
group: group::release
default_enabled: false
......@@ -14418,6 +14418,7 @@ Name of the feature that the callout is for.
| <a id="usercalloutfeaturenameenumunfinished_tag_cleanup_callout"></a>`UNFINISHED_TAG_CLEANUP_CALLOUT` | Callout feature name for unfinished_tag_cleanup_callout. |
| <a id="usercalloutfeaturenameenumwebhooks_moved"></a>`WEBHOOKS_MOVED` | Callout feature name for webhooks_moved. |
| <a id="usercalloutfeaturenameenumweb_ide_alert_dismissed"></a>`WEB_IDE_ALERT_DISMISSED` | Callout feature name for web_ide_alert_dismissed. |
| <a id="usercalloutfeaturenameenumweb_ide_ci_environments_guidance"></a>`WEB_IDE_CI_ENVIRONMENTS_GUIDANCE` | Callout feature name for web_ide_ci_environments_guidance. |
### `UserState`
......
......@@ -22044,6 +22044,9 @@ msgstr ""
msgid "No data to display"
msgstr ""
msgid "No deployments detected. Use environments to control your software's continuous deployment. %{linkStart}Learn more about deployment jobs.%{linkEnd}"
msgstr ""
msgid "No deployments found"
msgstr ""
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe InProductGuidanceEnvironmentsWebideExperiment, :experiment do
subject { described_class.new(project: project) }
let(:project) { create(:project, :repository) }
before do
stub_experiments(in_product_guidance_environments_webide: :candidate)
end
it 'excludes projects with environments' do
create(:environment, project: project)
expect(subject).to exclude(project: project)
end
it 'does not exlude projects without environments' do
expect(subject).not_to exclude(project: project)
end
end
......@@ -510,6 +510,7 @@ describe('RepoEditor', () => {
},
});
await vm.$nextTick();
await vm.$nextTick();
expect(vm.initEditor).toHaveBeenCalled();
});
......
import { GlLink } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import Environments from '~/ide/lib/alerts/environments.vue';
describe('~/ide/lib/alerts/environment.vue', () => {
let wrapper;
beforeEach(() => {
wrapper = mount(Environments);
});
it('shows a message regarding environments', () => {
expect(wrapper.text()).toBe(
"No deployments detected. Use environments to control your software's continuous deployment. Learn more about deployment jobs.",
);
});
it('links to the help page on environments', () => {
expect(wrapper.findComponent(GlLink).attributes('href')).toBe('/help/ci/environments/index.md');
});
});
......@@ -2,9 +2,11 @@ import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import getIdeProject from 'ee_else_ce/ide/queries/get_ide_project.query.graphql';
import Api from '~/api';
import dismissUserCallout from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql';
import services from '~/ide/services';
import { query } from '~/ide/services/gql';
import { query, mutate } from '~/ide/services/gql';
import { escapeFileUrl } from '~/lib/utils/url_utility';
import ciConfig from '~/pipeline_editor/graphql/queries/ci_config.graphql';
import { projectData } from '../mock_data';
jest.mock('~/api');
......@@ -299,4 +301,33 @@ describe('IDE services', () => {
});
});
});
describe('getCiConfig', () => {
const TEST_PROJECT_PATH = 'foo/bar';
const TEST_CI_CONFIG = 'test config';
it('queries with the given CI config and project', () => {
const result = { data: { ciConfig: { test: 'data' } } };
query.mockResolvedValue(result);
return services.getCiConfig(TEST_PROJECT_PATH, TEST_CI_CONFIG).then((data) => {
expect(data).toEqual(result.data.ciConfig);
expect(query).toHaveBeenCalledWith({
query: ciConfig,
variables: { projectPath: TEST_PROJECT_PATH, content: TEST_CI_CONFIG },
});
});
});
});
describe('dismissUserCallout', () => {
it('mutates the callout to dismiss', () => {
const result = { data: { callouts: { test: 'data' } } };
mutate.mockResolvedValue(result);
return services.dismissUserCallout('test').then((data) => {
expect(data).toEqual(result.data);
expect(mutate).toHaveBeenCalledWith({
mutation: dismissUserCallout,
variables: { input: { featureName: 'test' } },
});
});
});
});
});
import testAction from 'helpers/vuex_action_helper';
import service from '~/ide/services';
import {
detectEnvironmentsGuidance,
dismissEnvironmentsGuidance,
} from '~/ide/stores/actions/alert';
import * as types from '~/ide/stores/mutation_types';
jest.mock('~/ide/services');
describe('~/ide/stores/actions/alert', () => {
describe('detectEnvironmentsGuidance', () => {
it('should try to fetch CI info', () => {
const stages = ['a', 'b', 'c'];
service.getCiConfig.mockResolvedValue({ stages });
return testAction(
detectEnvironmentsGuidance,
'the content',
{ currentProjectId: 'gitlab/test' },
[{ type: types.DETECT_ENVIRONMENTS_GUIDANCE_ALERT, payload: stages }],
[],
() => expect(service.getCiConfig).toHaveBeenCalledWith('gitlab/test', 'the content'),
);
});
});
describe('dismissCallout', () => {
it('should try to dismiss the given callout', () => {
const callout = { featureName: 'test', dismissedAt: 'now' };
service.dismissUserCallout.mockResolvedValue({ userCalloutCreate: { userCallout: callout } });
return testAction(
dismissEnvironmentsGuidance,
undefined,
{},
[{ type: types.DISMISS_ENVIRONMENTS_GUIDANCE_ALERT }],
[],
() =>
expect(service.dismissUserCallout).toHaveBeenCalledWith(
'web_ide_ci_environments_guidance',
),
);
});
});
});
......@@ -4,6 +4,7 @@ import eventHub from '~/ide/eventhub';
import { createRouter } from '~/ide/ide_router';
import { createStore } from '~/ide/stores';
import {
init,
stageAllChanges,
unstageAllChanges,
toggleFileFinder,
......@@ -54,15 +55,15 @@ describe('Multi-file store actions', () => {
});
});
describe('setInitialData', () => {
it('commits initial data', (done) => {
store
.dispatch('setInitialData', { canCommit: true })
.then(() => {
expect(store.state.canCommit).toBeTruthy();
done();
})
.catch(done.fail);
describe('init', () => {
it('commits initial data and requests user callouts', () => {
return testAction(
init,
{ canCommit: true },
store.state,
[{ type: 'SET_INITIAL_DATA', payload: { canCommit: true } }],
[],
);
});
});
......
import { getAlert } from '~/ide/lib/alerts';
import EnvironmentsMessage from '~/ide/lib/alerts/environments.vue';
import { createStore } from '~/ide/stores';
import * as getters from '~/ide/stores/getters/alert';
import { file } from '../../helpers';
describe('IDE store alert getters', () => {
let localState;
let localStore;
beforeEach(() => {
localStore = createStore();
localState = localStore.state;
});
describe('alerts', () => {
describe('shows an alert about environments', () => {
let alert;
beforeEach(() => {
const f = file('.gitlab-ci.yml');
localState.openFiles.push(f);
localState.currentActivityView = 'repo-commit-section';
localState.environmentsGuidanceAlertDetected = true;
localState.environmentsGuidanceAlertDismissed = false;
const alertKey = getters.getAlert(localState)(f);
alert = getAlert(alertKey);
});
it('has a message suggesting to use environments', () => {
expect(alert.message).toEqual(EnvironmentsMessage);
});
it('dispatches to dismiss the callout on dismiss', () => {
jest.spyOn(localStore, 'dispatch').mockImplementation();
alert.dismiss(localStore);
expect(localStore.dispatch).toHaveBeenCalledWith('dismissEnvironmentsGuidance');
});
it('should be a tip alert', () => {
expect(alert.props).toEqual({ variant: 'tip' });
});
});
});
});
import * as types from '~/ide/stores/mutation_types';
import mutations from '~/ide/stores/mutations/alert';
describe('~/ide/stores/mutations/alert', () => {
const state = {};
describe(types.DETECT_ENVIRONMENTS_GUIDANCE_ALERT, () => {
it('checks the stages for any that configure environments', () => {
mutations[types.DETECT_ENVIRONMENTS_GUIDANCE_ALERT](state, {
nodes: [{ groups: { nodes: [{ jobs: { nodes: [{}] } }] } }],
});
expect(state.environmentsGuidanceAlertDetected).toBe(true);
mutations[types.DETECT_ENVIRONMENTS_GUIDANCE_ALERT](state, {
nodes: [{ groups: { nodes: [{ jobs: { nodes: [{ environment: {} }] } }] } }],
});
expect(state.environmentsGuidanceAlertDetected).toBe(false);
});
});
describe(types.DISMISS_ENVIRONMENTS_GUIDANCE_ALERT, () => {
it('stops environments guidance', () => {
mutations[types.DISMISS_ENVIRONMENTS_GUIDANCE_ALERT](state);
expect(state.environmentsGuidanceAlertDismissed).toBe(true);
});
});
});
......@@ -45,5 +45,35 @@ RSpec.describe IdeHelper do
)
end
end
context 'environments guidance experiment', :experiment do
before do
stub_experiments(in_product_guidance_environments_webide: :candidate)
self.instance_variable_set(:@project, project)
end
context 'when project has no enviornments' do
it 'enables environment guidance' do
expect(helper.ide_data).to include('enable-environments-guidance' => 'true')
end
context 'and the callout has been dismissed' do
it 'disables environment guidance' do
callout = create(:user_callout, feature_name: :web_ide_ci_environments_guidance, user: project.creator)
callout.update!(dismissed_at: Time.now - 1.week)
allow(helper).to receive(:current_user).and_return(User.find(project.creator.id))
expect(helper.ide_data).to include('enable-environments-guidance' => 'false')
end
end
end
context 'when the project has environments' do
it 'disables environment guidance' do
create(:environment, project: project)
expect(helper.ide_data).to include('enable-environments-guidance' => 'false')
end
end
end
end
end
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