Commit 594cf561 authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch 'master' into 'jivanvl-add-text-variable-input-from-variables'

# Conflicts:
#   app/assets/javascripts/monitoring/stores/actions.js
#   app/assets/javascripts/monitoring/stores/mutation_types.js
#   app/assets/javascripts/monitoring/stores/mutations.js
#   app/assets/javascripts/monitoring/stores/state.js
#   app/assets/javascripts/monitoring/utils.js
#   changelogs/unreleased/jivanvl-use-metrics-url-query-param.yml
#   spec/frontend/monitoring/store/mutations_spec.js
parents 42b11db5 a096aef7
...@@ -857,7 +857,7 @@ GEM ...@@ -857,7 +857,7 @@ GEM
re2 (1.2.0) re2 (1.2.0)
recaptcha (4.13.1) recaptcha (4.13.1)
json json
recursive-open-struct (1.1.0) recursive-open-struct (1.1.1)
redis (4.1.3) redis (4.1.3)
redis-actionpack (5.2.0) redis-actionpack (5.2.0)
actionpack (>= 5, < 7) actionpack (>= 5, < 7)
......
<script> <script>
import { GlNewDropdown, GlNewDropdownItem, GlTabs, GlTab } from '@gitlab/ui'; import { GlNewDropdown, GlNewDropdownItem, GlTabs, GlTab, GlButton } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import query from '../graphql/queries/details.query.graphql'; import query from '../graphql/queries/details.query.graphql';
import { fetchPolicies } from '~/lib/graphql'; import { fetchPolicies } from '~/lib/graphql';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default { export default {
statuses: { statuses: {
...@@ -19,7 +20,9 @@ export default { ...@@ -19,7 +20,9 @@ export default {
GlNewDropdownItem, GlNewDropdownItem,
GlTab, GlTab,
GlTabs, GlTabs,
GlButton,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
alertId: { alertId: {
type: String, type: String,
...@@ -29,6 +32,10 @@ export default { ...@@ -29,6 +32,10 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
newIssuePath: {
type: String,
required: true,
},
}, },
apollo: { apollo: {
alert: { alert: {
...@@ -52,9 +59,22 @@ export default { ...@@ -52,9 +59,22 @@ export default {
</script> </script>
<template> <template>
<div> <div>
<div v-if="alert" class="d-flex justify-content-between border-bottom pb-2 pt-1"> <div
<div></div> v-if="alert"
<gl-new-dropdown class="align-self-center" right> class="gl-display-flex justify-content-end gl-border-b-1 gl-border-b-gray-200 gl-border-b-solid gl-p-4"
>
<gl-button
v-if="glFeatures.createIssueFromAlertEnabled"
data-testid="createIssueBtn"
:href="newIssuePath"
category="primary"
variant="success"
>
{{ s__('AlertManagement|Create issue') }}
</gl-button>
</div>
<div class="gl-display-flex justify-content-end">
<gl-new-dropdown right>
<gl-new-dropdown-item <gl-new-dropdown-item
v-for="(label, field) in $options.statuses" v-for="(label, field) in $options.statuses"
:key="field" :key="field"
......
...@@ -7,7 +7,7 @@ Vue.use(VueApollo); ...@@ -7,7 +7,7 @@ Vue.use(VueApollo);
export default selector => { export default selector => {
const domEl = document.querySelector(selector); const domEl = document.querySelector(selector);
const { alertId, projectPath } = domEl.dataset; const { alertId, projectPath, newIssuePath } = domEl.dataset;
const apolloProvider = new VueApollo({ const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(), defaultClient: createDefaultClient(),
...@@ -25,6 +25,7 @@ export default selector => { ...@@ -25,6 +25,7 @@ export default selector => {
props: { props: {
alertId, alertId,
projectPath, projectPath,
newIssuePath,
}, },
}); });
}, },
......
...@@ -23,6 +23,8 @@ const Api = { ...@@ -23,6 +23,8 @@ const Api = {
projectMergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions', projectMergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions',
projectRunnersPath: '/api/:version/projects/:id/runners', projectRunnersPath: '/api/:version/projects/:id/runners',
projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches', projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches',
projectSearchPath: '/api/:version/projects/:id/search',
projectMilestonesPath: '/api/:version/projects/:id/milestones',
mergeRequestsPath: '/api/:version/merge_requests', mergeRequestsPath: '/api/:version/merge_requests',
groupLabelsPath: '/groups/:namespace_path/-/labels', groupLabelsPath: '/groups/:namespace_path/-/labels',
issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key', issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
...@@ -75,13 +77,11 @@ const Api = { ...@@ -75,13 +77,11 @@ const Api = {
const url = Api.buildUrl(Api.groupsPath); const url = Api.buildUrl(Api.groupsPath);
return axios return axios
.get(url, { .get(url, {
params: Object.assign( params: {
{
search: query, search: query,
per_page: DEFAULT_PER_PAGE, per_page: DEFAULT_PER_PAGE,
...options,
}, },
options,
),
}) })
.then(({ data }) => { .then(({ data }) => {
callback(data); callback(data);
...@@ -248,6 +248,23 @@ const Api = { ...@@ -248,6 +248,23 @@ const Api = {
.then(({ data }) => data); .then(({ data }) => data);
}, },
projectSearch(id, options = {}) {
const url = Api.buildUrl(Api.projectSearchPath).replace(':id', encodeURIComponent(id));
return axios.get(url, {
params: {
search: options.search,
scope: options.scope,
},
});
},
projectMilestones(id) {
const url = Api.buildUrl(Api.projectMilestonesPath).replace(':id', encodeURIComponent(id));
return axios.get(url);
},
mergeRequests(params = {}) { mergeRequests(params = {}) {
const url = Api.buildUrl(Api.mergeRequestsPath); const url = Api.buildUrl(Api.mergeRequestsPath);
...@@ -282,7 +299,7 @@ const Api = { ...@@ -282,7 +299,7 @@ const Api = {
}; };
return axios return axios
.get(url, { .get(url, {
params: Object.assign({}, defaults, options), params: { ...defaults, ...options },
}) })
.then(({ data }) => callback(data)) .then(({ data }) => callback(data))
.catch(() => flash(__('Something went wrong while fetching projects'))); .catch(() => flash(__('Something went wrong while fetching projects')));
...@@ -365,13 +382,11 @@ const Api = { ...@@ -365,13 +382,11 @@ const Api = {
users(query, options) { users(query, options) {
const url = Api.buildUrl(this.usersPath); const url = Api.buildUrl(this.usersPath);
return axios.get(url, { return axios.get(url, {
params: Object.assign( params: {
{
search: query, search: query,
per_page: DEFAULT_PER_PAGE, per_page: DEFAULT_PER_PAGE,
...options,
}, },
options,
),
}); });
}, },
...@@ -402,7 +417,7 @@ const Api = { ...@@ -402,7 +417,7 @@ const Api = {
}; };
return axios return axios
.get(url, { .get(url, {
params: Object.assign({}, defaults, options), params: { ...defaults, ...options },
}) })
.then(({ data }) => callback(data)) .then(({ data }) => callback(data))
.catch(() => flash(__('Something went wrong while fetching projects'))); .catch(() => flash(__('Something went wrong while fetching projects')));
......
...@@ -22,7 +22,7 @@ function eventHasModifierKeys(event) { ...@@ -22,7 +22,7 @@ function eventHasModifierKeys(event) {
export default class ShortcutsBlob extends Shortcuts { export default class ShortcutsBlob extends Shortcuts {
constructor(opts) { constructor(opts) {
const options = Object.assign({}, defaults, opts); const options = { ...defaults, ...opts };
super(options.skipResetBindings); super(options.skipResetBindings);
this.options = options; this.options = options;
......
...@@ -17,7 +17,7 @@ const defaults = { ...@@ -17,7 +17,7 @@ const defaults = {
class BlobForkSuggestion { class BlobForkSuggestion {
constructor(options) { constructor(options) {
this.elementMap = Object.assign({}, defaults, options); this.elementMap = { ...defaults, ...options };
this.onOpenButtonClick = this.onOpenButtonClick.bind(this); this.onOpenButtonClick = this.onOpenButtonClick.bind(this);
this.onCancelButtonClick = this.onCancelButtonClick.bind(this); this.onCancelButtonClick = this.onCancelButtonClick.bind(this);
} }
......
...@@ -19,14 +19,15 @@ export function getBoardSortableDefaultOptions(obj) { ...@@ -19,14 +19,15 @@ export function getBoardSortableDefaultOptions(obj) {
const touchEnabled = const touchEnabled =
'ontouchstart' in window || (window.DocumentTouch && document instanceof DocumentTouch); 'ontouchstart' in window || (window.DocumentTouch && document instanceof DocumentTouch);
const defaultSortOptions = Object.assign({}, sortableConfig, { const defaultSortOptions = {
...sortableConfig,
filter: '.no-drag', filter: '.no-drag',
delay: touchEnabled ? 100 : 0, delay: touchEnabled ? 100 : 0,
scrollSensitivity: touchEnabled ? 60 : 100, scrollSensitivity: touchEnabled ? 60 : 100,
scrollSpeed: 20, scrollSpeed: 20,
onStart: sortableStart, onStart: sortableStart,
onEnd: sortableEnd, onEnd: sortableEnd,
}); };
Object.keys(obj).forEach(key => { Object.keys(obj).forEach(key => {
defaultSortOptions[key] = obj[key]; defaultSortOptions[key] = obj[key];
......
...@@ -2,7 +2,7 @@ import DropLab from './droplab/drop_lab'; ...@@ -2,7 +2,7 @@ import DropLab from './droplab/drop_lab';
import ISetter from './droplab/plugins/input_setter'; import ISetter from './droplab/plugins/input_setter';
// Todo: Remove this when fixing issue in input_setter plugin // Todo: Remove this when fixing issue in input_setter plugin
const InputSetter = Object.assign({}, ISetter); const InputSetter = { ...ISetter };
class CloseReopenReportToggle { class CloseReopenReportToggle {
constructor(opts = {}) { constructor(opts = {}) {
......
...@@ -325,7 +325,7 @@ export default class Clusters { ...@@ -325,7 +325,7 @@ export default class Clusters {
handleClusterStatusSuccess(data) { handleClusterStatusSuccess(data) {
const prevStatus = this.store.state.status; const prevStatus = this.store.state.status;
const prevApplicationMap = Object.assign({}, this.store.state.applications); const prevApplicationMap = { ...this.store.state.applications };
this.store.updateStateFromServer(data.data); this.store.updateStateFromServer(data.data);
......
...@@ -2,7 +2,7 @@ import DropLab from './droplab/drop_lab'; ...@@ -2,7 +2,7 @@ import DropLab from './droplab/drop_lab';
import ISetter from './droplab/plugins/input_setter'; import ISetter from './droplab/plugins/input_setter';
// Todo: Remove this when fixing issue in input_setter plugin // Todo: Remove this when fixing issue in input_setter plugin
const InputSetter = Object.assign({}, ISetter); const InputSetter = { ...ISetter };
class CommentTypeToggle { class CommentTypeToggle {
constructor(opts = {}) { constructor(opts = {}) {
......
...@@ -13,7 +13,7 @@ import { ...@@ -13,7 +13,7 @@ import {
import confidentialMergeRequestState from './confidential_merge_request/state'; import confidentialMergeRequestState from './confidential_merge_request/state';
// Todo: Remove this when fixing issue in input_setter plugin // Todo: Remove this when fixing issue in input_setter plugin
const InputSetter = Object.assign({}, ISetter); const InputSetter = { ...ISetter };
const CREATE_MERGE_REQUEST = 'create-mr'; const CREATE_MERGE_REQUEST = 'create-mr';
const CREATE_BRANCH = 'create-branch'; const CREATE_BRANCH = 'create-branch';
......
...@@ -84,7 +84,7 @@ export default { ...@@ -84,7 +84,7 @@ export default {
events.forEach(item => { events.forEach(item => {
if (!item) return; if (!item) return;
const eventItem = Object.assign({}, DEFAULT_EVENT_OBJECTS[stage.slug], item); const eventItem = { ...DEFAULT_EVENT_OBJECTS[stage.slug], ...item };
eventItem.totalTime = eventItem.total_time; eventItem.totalTime = eventItem.total_time;
......
...@@ -28,9 +28,7 @@ export default { ...@@ -28,9 +28,7 @@ export default {
return this.label === null; return this.label === null;
}, },
pinStyle() { pinStyle() {
return this.repositioning return this.repositioning ? { ...this.position, cursor: 'move' } : this.position;
? Object.assign({}, this.position, { cursor: 'move' })
: this.position;
}, },
pinLabel() { pinLabel() {
return this.isNewNote return this.isNewNote
......
...@@ -35,6 +35,7 @@ import { ...@@ -35,6 +35,7 @@ import {
UPDATE_NOTE_ERROR, UPDATE_NOTE_ERROR,
designDeletionError, designDeletionError,
} from '../../utils/error_messages'; } from '../../utils/error_messages';
import { trackDesignDetailView } from '../../utils/tracking';
import { DESIGNS_ROUTE_NAME } from '../../router/constants'; import { DESIGNS_ROUTE_NAME } from '../../router/constants';
export default { export default {
...@@ -257,8 +258,21 @@ export default { ...@@ -257,8 +258,21 @@ export default {
query: this.$route.query, query: this.$route.query,
}); });
}, },
trackEvent() {
trackDesignDetailView(
'issue-design-collection',
this.$route.query.version || this.latestVersionId,
this.isLatestVersion,
);
},
},
beforeRouteEnter(to, from, next) {
next(vm => {
vm.trackEvent();
});
}, },
beforeRouteUpdate(to, from, next) { beforeRouteUpdate(to, from, next) {
this.trackEvent();
this.closeCommentForm(); this.closeCommentForm();
next(); next();
}, },
......
import Tracking from '~/tracking';
function assembleDesignPayload(payloadArr) {
return {
value: {
'internal-object-refrerer': payloadArr[0],
'version-number': payloadArr[1],
'current-version': payloadArr[2],
},
};
}
// Tracking Constants
const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design';
// eslint-disable-next-line import/prefer-default-export
export function trackDesignDetailView(refrerer = '', designVersion = 1, latestVersion = false) {
Tracking.event(DESIGN_TRACKING_PAGE_NAME, 'design_viewed', {
label: 'design_viewed',
...assembleDesignPayload([refrerer, designVersion, latestVersion]),
});
}
...@@ -233,7 +233,7 @@ export function trimFirstCharOfLineContent(line = {}) { ...@@ -233,7 +233,7 @@ export function trimFirstCharOfLineContent(line = {}) {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
delete line.text; delete line.text;
const parsedLine = Object.assign({}, line); const parsedLine = { ...line };
if (line.rich_text) { if (line.rich_text) {
const firstChar = parsedLine.rich_text.charAt(0); const firstChar = parsedLine.rich_text.charAt(0);
......
...@@ -58,13 +58,14 @@ export default class EnvironmentsStore { ...@@ -58,13 +58,14 @@ export default class EnvironmentsStore {
let filtered = {}; let filtered = {};
if (env.size > 1) { if (env.size > 1) {
filtered = Object.assign({}, env, { filtered = {
...env,
isFolder: true, isFolder: true,
isLoadingFolderContent: oldEnvironmentState.isLoading || false, isLoadingFolderContent: oldEnvironmentState.isLoading || false,
folderName: env.name, folderName: env.name,
isOpen: oldEnvironmentState.isOpen || false, isOpen: oldEnvironmentState.isOpen || false,
children: oldEnvironmentState.children || [], children: oldEnvironmentState.children || [],
}); };
} }
if (env.latest) { if (env.latest) {
...@@ -166,7 +167,7 @@ export default class EnvironmentsStore { ...@@ -166,7 +167,7 @@ export default class EnvironmentsStore {
let updated = env; let updated = env;
if (env.latest) { if (env.latest) {
updated = Object.assign({}, env, env.latest); updated = { ...env, ...env.latest };
delete updated.latest; delete updated.latest;
} else { } else {
updated = env; updated = env;
...@@ -192,7 +193,7 @@ export default class EnvironmentsStore { ...@@ -192,7 +193,7 @@ export default class EnvironmentsStore {
const { environments } = this.state; const { environments } = this.state;
const updatedEnvironments = environments.map(env => { const updatedEnvironments = environments.map(env => {
const updateEnv = Object.assign({}, env); const updateEnv = { ...env };
if (env.id === environment.id) { if (env.id === environment.id) {
updateEnv[prop] = newValue; updateEnv[prop] = newValue;
} }
......
...@@ -120,7 +120,7 @@ export default class FilteredSearchDropdownManager { ...@@ -120,7 +120,7 @@ export default class FilteredSearchDropdownManager {
filter: key, filter: key,
}; };
const extraArguments = mappingKey.extraArguments || {}; const extraArguments = mappingKey.extraArguments || {};
const glArguments = Object.assign({}, defaultArguments, extraArguments); const glArguments = { ...defaultArguments, ...extraArguments };
// Passing glArguments to `new glClass(<arguments>)` // Passing glArguments to `new glClass(<arguments>)`
mappingKey.reference = new (Function.prototype.bind.apply(glClass, [null, glArguments]))(); mappingKey.reference = new (Function.prototype.bind.apply(glClass, [null, glArguments]))();
......
...@@ -2,14 +2,12 @@ import { uniq } from 'lodash'; ...@@ -2,14 +2,12 @@ import { uniq } from 'lodash';
class RecentSearchesStore { class RecentSearchesStore {
constructor(initialState = {}, allowedKeys) { constructor(initialState = {}, allowedKeys) {
this.state = Object.assign( this.state = {
{
isLocalStorageAvailable: true, isLocalStorageAvailable: true,
recentSearches: [], recentSearches: [],
allowedKeys, allowedKeys,
}, ...initialState,
initialState, };
);
} }
addRecentSearch(newSearch) { addRecentSearch(newSearch) {
......
...@@ -595,13 +595,14 @@ class GitLabDropdown { ...@@ -595,13 +595,14 @@ class GitLabDropdown {
return renderItem({ return renderItem({
instance: this, instance: this,
options: Object.assign({}, this.options, { options: {
...this.options,
icon: this.icon, icon: this.icon,
highlight: this.highlight, highlight: this.highlight,
highlightText: text => this.highlightTextMatches(text, this.filterInput.val()), highlightText: text => this.highlightTextMatches(text, this.filterInput.val()),
highlightTemplate: this.highlightTemplate.bind(this), highlightTemplate: this.highlightTemplate.bind(this),
parent, parent,
}), },
data, data,
group, group,
index, index,
......
...@@ -8,7 +8,7 @@ export default class GLForm { ...@@ -8,7 +8,7 @@ export default class GLForm {
constructor(form, enableGFM = {}) { constructor(form, enableGFM = {}) {
this.form = form; this.form = form;
this.textarea = this.form.find('textarea.js-gfm-input'); this.textarea = this.form.find('textarea.js-gfm-input');
this.enableGFM = Object.assign({}, defaultAutocompleteConfig, enableGFM); this.enableGFM = { ...defaultAutocompleteConfig, ...enableGFM };
// Disable autocomplete for keywords which do not have dataSources available // Disable autocomplete for keywords which do not have dataSources available
const dataSources = (gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources) || {}; const dataSources = (gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources) || {};
Object.keys(this.enableGFM).forEach(item => { Object.keys(this.enableGFM).forEach(item => {
......
...@@ -2,7 +2,7 @@ import { visitUrl } from '../lib/utils/url_utility'; ...@@ -2,7 +2,7 @@ import { visitUrl } from '../lib/utils/url_utility';
import DropLab from '../droplab/drop_lab'; import DropLab from '../droplab/drop_lab';
import ISetter from '../droplab/plugins/input_setter'; import ISetter from '../droplab/plugins/input_setter';
const InputSetter = Object.assign({}, ISetter); const InputSetter = { ...ISetter };
const NEW_PROJECT = 'new-project'; const NEW_PROJECT = 'new-project';
const NEW_SUBGROUP = 'new-subgroup'; const NEW_SUBGROUP = 'new-subgroup';
......
<script> <script>
import $ from 'jquery';
import { mapActions } from 'vuex'; import { mapActions } from 'vuex';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import { GlModal } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
import ListItem from './list_item.vue'; import ListItem from './list_item.vue';
...@@ -11,7 +10,7 @@ export default { ...@@ -11,7 +10,7 @@ export default {
components: { components: {
Icon, Icon,
ListItem, ListItem,
GlModal: DeprecatedModal2, GlModal,
}, },
directives: { directives: {
tooltip, tooltip,
...@@ -58,7 +57,7 @@ export default { ...@@ -58,7 +57,7 @@ export default {
methods: { methods: {
...mapActions(['stageAllChanges', 'unstageAllChanges', 'discardAllChanges']), ...mapActions(['stageAllChanges', 'unstageAllChanges', 'discardAllChanges']),
openDiscardModal() { openDiscardModal() {
$('#discard-all-changes').modal('show'); this.$refs.discardAllModal.show();
}, },
unstageAndDiscardAllChanges() { unstageAndDiscardAllChanges() {
this.unstageAllChanges(); this.unstageAllChanges();
...@@ -114,11 +113,12 @@ export default { ...@@ -114,11 +113,12 @@ export default {
</p> </p>
<gl-modal <gl-modal
v-if="!stagedList" v-if="!stagedList"
id="discard-all-changes" ref="discardAllModal"
:footer-primary-button-text="__('Discard all changes')" ok-variant="danger"
:header-title-text="__('Discard all changes?')" modal-id="discard-all-changes"
footer-primary-button-variant="danger" :ok-title="__('Discard all changes')"
@submit="unstageAndDiscardAllChanges" :title="__('Discard all changes?')"
@ok="unstageAndDiscardAllChanges"
> >
{{ $options.discardModalText }} {{ $options.discardModalText }}
</gl-modal> </gl-modal>
......
...@@ -3,6 +3,7 @@ import Vue from 'vue'; ...@@ -3,6 +3,7 @@ import Vue from 'vue';
import { mapActions, mapGetters, mapState } from 'vuex'; import { mapActions, mapGetters, mapState } from 'vuex';
import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui'; import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { modalTypes } from '../constants';
import FindFile from '~/vue_shared/components/file_finder/index.vue'; import FindFile from '~/vue_shared/components/file_finder/index.vue';
import NewModal from './new_dropdown/modal.vue'; import NewModal from './new_dropdown/modal.vue';
import IdeSidebar from './ide_side_bar.vue'; import IdeSidebar from './ide_side_bar.vue';
...@@ -67,7 +68,7 @@ export default { ...@@ -67,7 +68,7 @@ export default {
document.querySelector('.navbar-gitlab').classList.add(`theme-${this.themeName}`); document.querySelector('.navbar-gitlab').classList.add(`theme-${this.themeName}`);
}, },
methods: { methods: {
...mapActions(['toggleFileFinder', 'openNewEntryModal']), ...mapActions(['toggleFileFinder']),
onBeforeUnload(e = {}) { onBeforeUnload(e = {}) {
const returnValue = __('Are you sure you want to lose unsaved changes?'); const returnValue = __('Are you sure you want to lose unsaved changes?');
...@@ -81,6 +82,9 @@ export default { ...@@ -81,6 +82,9 @@ export default {
openFile(file) { openFile(file) {
this.$router.push(`/project${file.url}`); this.$router.push(`/project${file.url}`);
}, },
createNewFile() {
this.$refs.newModal.open(modalTypes.blob);
},
}, },
}; };
</script> </script>
...@@ -137,7 +141,7 @@ export default { ...@@ -137,7 +141,7 @@ export default {
variant="success" variant="success"
:title="__('New file')" :title="__('New file')"
:aria-label="__('New file')" :aria-label="__('New file')"
@click="openNewEntryModal({ type: 'blob' })" @click="createNewFile()"
> >
{{ __('New file') }} {{ __('New file') }}
</gl-deprecated-button> </gl-deprecated-button>
...@@ -159,6 +163,6 @@ export default { ...@@ -159,6 +163,6 @@ export default {
<component :is="rightPaneComponent" v-if="currentProjectId" /> <component :is="rightPaneComponent" v-if="currentProjectId" />
</div> </div>
<ide-status-bar /> <ide-status-bar />
<new-modal /> <new-modal ref="newModal" />
</article> </article>
</template> </template>
<script> <script>
import { mapState, mapGetters, mapActions } from 'vuex'; import { mapState, mapGetters, mapActions } from 'vuex';
import { modalTypes } from '../constants';
import IdeTreeList from './ide_tree_list.vue'; import IdeTreeList from './ide_tree_list.vue';
import Upload from './new_dropdown/upload.vue'; import Upload from './new_dropdown/upload.vue';
import NewEntryButton from './new_dropdown/button.vue'; import NewEntryButton from './new_dropdown/button.vue';
import NewModal from './new_dropdown/modal.vue';
export default { export default {
components: { components: {
Upload, Upload,
IdeTreeList, IdeTreeList,
NewEntryButton, NewEntryButton,
NewModal,
}, },
computed: { computed: {
...mapState(['currentBranchId']), ...mapState(['currentBranchId']),
...@@ -26,7 +29,13 @@ export default { ...@@ -26,7 +29,13 @@ export default {
} }
}, },
methods: { methods: {
...mapActions(['updateViewer', 'openNewEntryModal', 'createTempEntry', 'resetOpenFiles']), ...mapActions(['updateViewer', 'createTempEntry', 'resetOpenFiles']),
createNewFile() {
this.$refs.newModal.open(modalTypes.blob);
},
createNewFolder() {
this.$refs.newModal.open(modalTypes.tree);
},
}, },
}; };
</script> </script>
...@@ -41,7 +50,7 @@ export default { ...@@ -41,7 +50,7 @@ export default {
:show-label="false" :show-label="false"
class="d-flex border-0 p-0 mr-3 qa-new-file" class="d-flex border-0 p-0 mr-3 qa-new-file"
icon="doc-new" icon="doc-new"
@click="openNewEntryModal({ type: 'blob' })" @click="createNewFile()"
/> />
<upload <upload
:show-label="false" :show-label="false"
...@@ -54,9 +63,10 @@ export default { ...@@ -54,9 +63,10 @@ export default {
:show-label="false" :show-label="false"
class="d-flex border-0 p-0" class="d-flex border-0 p-0"
icon="folder-new" icon="folder-new"
@click="openNewEntryModal({ type: 'tree' })" @click="createNewFolder()"
/> />
</div> </div>
<new-modal ref="newModal" />
</template> </template>
</ide-tree-list> </ide-tree-list>
</template> </template>
...@@ -25,13 +25,13 @@ export default { ...@@ -25,13 +25,13 @@ export default {
<div class="ide-nav-form p-0"> <div class="ide-nav-form p-0">
<tabs v-if="showMergeRequests" stop-propagation> <tabs v-if="showMergeRequests" stop-propagation>
<tab active> <tab active>
<template slot="title"> <template #title>
{{ __('Branches') }} {{ __('Branches') }}
</template> </template>
<branches-search-list /> <branches-search-list />
</tab> </tab>
<tab> <tab>
<template slot="title"> <template #title>
{{ __('Merge Requests') }} {{ __('Merge Requests') }}
</template> </template>
<merge-request-search-list /> <merge-request-search-list />
......
...@@ -4,12 +4,14 @@ import icon from '~/vue_shared/components/icon.vue'; ...@@ -4,12 +4,14 @@ import icon from '~/vue_shared/components/icon.vue';
import upload from './upload.vue'; import upload from './upload.vue';
import ItemButton from './button.vue'; import ItemButton from './button.vue';
import { modalTypes } from '../../constants'; import { modalTypes } from '../../constants';
import NewModal from '../new_dropdown/modal.vue';
export default { export default {
components: { components: {
icon, icon,
upload, upload,
ItemButton, ItemButton,
NewModal,
}, },
props: { props: {
type: { type: {
...@@ -37,9 +39,9 @@ export default { ...@@ -37,9 +39,9 @@ export default {
}, },
}, },
methods: { methods: {
...mapActions(['createTempEntry', 'openNewEntryModal', 'deleteEntry']), ...mapActions(['createTempEntry', 'deleteEntry']),
createNewItem(type) { createNewItem(type) {
this.openNewEntryModal({ type, path: this.path }); this.$refs.newModal.open(type, this.path);
this.$emit('toggle', false); this.$emit('toggle', false);
}, },
openDropdown() { openDropdown() {
...@@ -109,5 +111,6 @@ export default { ...@@ -109,5 +111,6 @@ export default {
</li> </li>
</ul> </ul>
</div> </div>
<new-modal ref="newModal" />
</div> </div>
</template> </template>
<script> <script>
import $ from 'jquery';
import { mapActions, mapState, mapGetters } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import flash from '~/flash'; import flash from '~/flash';
import { __, sprintf, s__ } from '~/locale'; import { __, sprintf, s__ } from '~/locale';
import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import { GlModal } from '@gitlab/ui';
import { modalTypes } from '../../constants'; import { modalTypes } from '../../constants';
export default { export default {
components: { components: {
GlModal: DeprecatedModal2, GlModal,
}, },
data() { data() {
return { return {
name: '', name: '',
type: modalTypes.blob,
path: '',
}; };
}, },
computed: { computed: {
...mapState(['entries', 'entryModal']), ...mapState(['entries']),
...mapGetters('fileTemplates', ['templateTypes']), ...mapGetters('fileTemplates', ['templateTypes']),
entryName: { entryName: {
get() { get() {
const entryPath = this.entryModal.entry.path; if (this.type === modalTypes.rename) {
return this.name || this.path;
if (this.entryModal.type === modalTypes.rename) {
return this.name || entryPath;
} }
return this.name || (entryPath ? `${entryPath}/` : ''); return this.name || (this.path ? `${this.path}/` : '');
}, },
set(val) { set(val) {
this.name = val.trim(); this.name = val.trim();
}, },
}, },
modalTitle() { modalTitle() {
if (this.entryModal.type === modalTypes.tree) { const entry = this.entries[this.path];
if (this.type === modalTypes.tree) {
return __('Create new directory'); return __('Create new directory');
} else if (this.entryModal.type === modalTypes.rename) { } else if (this.type === modalTypes.rename) {
return this.entryModal.entry.type === modalTypes.tree return entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file');
? __('Rename folder')
: __('Rename file');
} }
return __('Create new file'); return __('Create new file');
}, },
buttonLabel() { buttonLabel() {
if (this.entryModal.type === modalTypes.tree) { const entry = this.entries[this.path];
if (this.type === modalTypes.tree) {
return __('Create directory'); return __('Create directory');
} else if (this.entryModal.type === modalTypes.rename) { } else if (this.type === modalTypes.rename) {
return this.entryModal.entry.type === modalTypes.tree return entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file');
? __('Rename folder')
: __('Rename file');
} }
return __('Create file'); return __('Create file');
}, },
isCreatingNewFile() { isCreatingNewFile() {
return this.entryModal.type === 'blob'; return this.type === modalTypes.blob;
}, },
placeholder() { placeholder() {
return this.isCreatingNewFile ? 'dir/file_name' : 'dir/'; return this.isCreatingNewFile ? 'dir/file_name' : 'dir/';
...@@ -64,7 +63,7 @@ export default { ...@@ -64,7 +63,7 @@ export default {
methods: { methods: {
...mapActions(['createTempEntry', 'renameEntry']), ...mapActions(['createTempEntry', 'renameEntry']),
submitForm() { submitForm() {
if (this.entryModal.type === modalTypes.rename) { if (this.type === modalTypes.rename) {
if (this.entries[this.entryName] && !this.entries[this.entryName].deleted) { if (this.entries[this.entryName] && !this.entries[this.entryName].deleted) {
flash( flash(
sprintf(s__('The name "%{name}" is already taken in this directory.'), { sprintf(s__('The name "%{name}" is already taken in this directory.'), {
...@@ -82,7 +81,7 @@ export default { ...@@ -82,7 +81,7 @@ export default {
parentPath = parentPath.join('/'); parentPath = parentPath.join('/');
this.renameEntry({ this.renameEntry({
path: this.entryModal.entry.path, path: this.path,
name: entryName, name: entryName,
parentPath, parentPath,
}); });
...@@ -90,17 +89,17 @@ export default { ...@@ -90,17 +89,17 @@ export default {
} else { } else {
this.createTempEntry({ this.createTempEntry({
name: this.name, name: this.name,
type: this.entryModal.type, type: this.type,
}); });
} }
}, },
createFromTemplate(template) { createFromTemplate(template) {
this.createTempEntry({ this.createTempEntry({
name: template.name, name: template.name,
type: this.entryModal.type, type: this.type,
}); });
$('#ide-new-entry').modal('toggle'); this.$refs.modal.toggle();
}, },
focusInput() { focusInput() {
const name = this.entries[this.entryName] ? this.entries[this.entryName].name : null; const name = this.entries[this.entryName] ? this.entries[this.entryName].name : null;
...@@ -112,8 +111,23 @@ export default { ...@@ -112,8 +111,23 @@ export default {
this.$refs.fieldName.setSelectionRange(inputValue.indexOf(name), inputValue.length); this.$refs.fieldName.setSelectionRange(inputValue.indexOf(name), inputValue.length);
} }
}, },
closedModal() { resetData() {
this.name = ''; this.name = '';
this.path = '';
this.type = modalTypes.blob;
},
open(type = modalTypes.blob, path = '') {
this.type = type;
this.path = path;
this.$refs.modal.show();
// wait for modal to show first
this.$nextTick(() => {
this.focusInput();
});
},
close() {
this.$refs.modal.hide();
}, },
}, },
}; };
...@@ -121,15 +135,15 @@ export default { ...@@ -121,15 +135,15 @@ export default {
<template> <template>
<gl-modal <gl-modal
id="ide-new-entry" ref="modal"
class="qa-new-file-modal" modal-id="ide-new-entry"
:header-title-text="modalTitle" modal-class="qa-new-file-modal"
:footer-primary-button-text="buttonLabel" :title="modalTitle"
footer-primary-button-variant="success" :ok-title="buttonLabel"
modal-size="lg" ok-variant="success"
@submit="submitForm" size="lg"
@open="focusInput" @ok="submitForm"
@closed="closedModal" @hide="resetData"
> >
<div class="form-group row"> <div class="form-group row">
<label class="label-bold col-form-label col-sm-2"> {{ __('Name') }} </label> <label class="label-bold col-form-label col-sm-2"> {{ __('Name') }} </label>
......
...@@ -78,6 +78,7 @@ export const commitItemIconMap = { ...@@ -78,6 +78,7 @@ export const commitItemIconMap = {
export const modalTypes = { export const modalTypes = {
rename: 'rename', rename: 'rename',
tree: 'tree', tree: 'tree',
blob: 'blob',
}; };
export const commitActionTypes = { export const commitActionTypes = {
......
...@@ -14,13 +14,12 @@ export const computeDiff = (originalContent, newContent) => { ...@@ -14,13 +14,12 @@ export const computeDiff = (originalContent, newContent) => {
endLineNumber: lineNumber + change.count - 1, endLineNumber: lineNumber + change.count - 1,
}); });
} else if ('added' in change || 'removed' in change) { } else if ('added' in change || 'removed' in change) {
acc.push( acc.push({
Object.assign({}, change, { ...change,
lineNumber, lineNumber,
modified: undefined, modified: undefined,
endLineNumber: lineNumber + change.count - 1, endLineNumber: lineNumber + change.count - 1,
}), });
);
} }
if (!change.removed) { if (!change.removed) {
......
import $ from 'jquery';
import Vue from 'vue'; import Vue from 'vue';
import { escape } from 'lodash'; import { escape } from 'lodash';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
...@@ -176,13 +175,6 @@ export const setLinks = ({ commit }, links) => commit(types.SET_LINKS, links); ...@@ -176,13 +175,6 @@ export const setLinks = ({ commit }, links) => commit(types.SET_LINKS, links);
export const setErrorMessage = ({ commit }, errorMessage) => export const setErrorMessage = ({ commit }, errorMessage) =>
commit(types.SET_ERROR_MESSAGE, errorMessage); commit(types.SET_ERROR_MESSAGE, errorMessage);
export const openNewEntryModal = ({ commit }, { type, path = '' }) => {
commit(types.OPEN_NEW_ENTRY_MODAL, { type, path });
// open the modal manually so we don't mess around with dropdown/rows
$('#ide-new-entry').modal('show');
};
export const deleteEntry = ({ commit, dispatch, state }, path) => { export const deleteEntry = ({ commit, dispatch, state }, path) => {
const entry = state.entries[path]; const entry = state.entries[path];
const { prevPath, prevName, prevParentPath } = entry; const { prevPath, prevName, prevParentPath } = entry;
......
...@@ -73,7 +73,6 @@ export const RESET_OPEN_FILES = 'RESET_OPEN_FILES'; ...@@ -73,7 +73,6 @@ export const RESET_OPEN_FILES = 'RESET_OPEN_FILES';
export const SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE'; export const SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE';
export const OPEN_NEW_ENTRY_MODAL = 'OPEN_NEW_ENTRY_MODAL';
export const DELETE_ENTRY = 'DELETE_ENTRY'; export const DELETE_ENTRY = 'DELETE_ENTRY';
export const RENAME_ENTRY = 'RENAME_ENTRY'; export const RENAME_ENTRY = 'RENAME_ENTRY';
export const REVERT_RENAME_ENTRY = 'REVERT_RENAME_ENTRY'; export const REVERT_RENAME_ENTRY = 'REVERT_RENAME_ENTRY';
......
...@@ -192,15 +192,6 @@ export default { ...@@ -192,15 +192,6 @@ export default {
[types.SET_ERROR_MESSAGE](state, errorMessage) { [types.SET_ERROR_MESSAGE](state, errorMessage) {
Object.assign(state, { errorMessage }); Object.assign(state, { errorMessage });
}, },
[types.OPEN_NEW_ENTRY_MODAL](state, { type, path }) {
Object.assign(state, {
entryModal: {
type,
path,
entry: { ...state.entries[path] },
},
});
},
[types.DELETE_ENTRY](state, path) { [types.DELETE_ENTRY](state, path) {
const entry = state.entries[path]; const entry = state.entries[path];
const { tempFile = false } = entry; const { tempFile = false } = entry;
......
...@@ -16,9 +16,7 @@ export default { ...@@ -16,9 +16,7 @@ export default {
}); });
Object.assign(state, { Object.assign(state, {
projects: Object.assign({}, state.projects, { projects: { ...state.projects, [projectPath]: project },
[projectPath]: project,
}),
}); });
}, },
[types.TOGGLE_EMPTY_STATE](state, { projectPath, value }) { [types.TOGGLE_EMPTY_STATE](state, { projectPath, value }) {
......
...@@ -14,12 +14,13 @@ export default { ...@@ -14,12 +14,13 @@ export default {
}, },
[types.CREATE_TREE](state, { treePath }) { [types.CREATE_TREE](state, { treePath }) {
Object.assign(state, { Object.assign(state, {
trees: Object.assign({}, state.trees, { trees: {
...state.trees,
[treePath]: { [treePath]: {
tree: [], tree: [],
loading: true, loading: true,
}, },
}), },
}); });
}, },
[types.SET_DIRECTORY_DATA](state, { data, treePath }) { [types.SET_DIRECTORY_DATA](state, { data, treePath }) {
......
...@@ -32,9 +32,7 @@ export function removeCommentIndicator(imageFrameEl) { ...@@ -32,9 +32,7 @@ export function removeCommentIndicator(imageFrameEl) {
commentIndicatorEl.remove(); commentIndicatorEl.remove();
} }
return Object.assign({}, meta, { return { ...meta, removed: willRemove };
removed: willRemove,
});
} }
export function showCommentIndicator(imageFrameEl, coordinate) { export function showCommentIndicator(imageFrameEl, coordinate) {
......
...@@ -4,12 +4,7 @@ export function setPositionDataAttribute(el, options) { ...@@ -4,12 +4,7 @@ export function setPositionDataAttribute(el, options) {
const { x, y, width, height } = options; const { x, y, width, height } = options;
const { position } = el.dataset; const { position } = el.dataset;
const positionObject = Object.assign({}, JSON.parse(position), { const positionObject = { ...JSON.parse(position), x, y, width, height };
x,
y,
width,
height,
});
el.setAttribute('data-position', JSON.stringify(positionObject)); el.setAttribute('data-position', JSON.stringify(positionObject));
} }
......
...@@ -75,9 +75,7 @@ export default class ImageDiff { ...@@ -75,9 +75,7 @@ export default class ImageDiff {
if (this.renderCommentBadge) { if (this.renderCommentBadge) {
imageDiffHelper.addImageCommentBadge(this.imageFrameEl, options); imageDiffHelper.addImageCommentBadge(this.imageFrameEl, options);
} else { } else {
const numberBadgeOptions = Object.assign({}, options, { const numberBadgeOptions = { ...options, badgeText: index + 1 };
badgeText: index + 1,
});
imageDiffHelper.addImageBadge(this.imageFrameEl, numberBadgeOptions); imageDiffHelper.addImageBadge(this.imageFrameEl, numberBadgeOptions);
} }
......
...@@ -220,7 +220,7 @@ export const fetchJobsForStage = ({ dispatch }, stage = {}) => { ...@@ -220,7 +220,7 @@ export const fetchJobsForStage = ({ dispatch }, stage = {}) => {
}, },
}) })
.then(({ data }) => { .then(({ data }) => {
const retriedJobs = data.retried.map(job => Object.assign({}, job, { retried: true })); const retriedJobs = data.retried.map(job => ({ ...job, retried: true }));
const jobs = data.latest_statuses.concat(retriedJobs); const jobs = data.latest_statuses.concat(retriedJobs);
dispatch('receiveJobsForStageSuccess', jobs); dispatch('receiveJobsForStageSuccess', jobs);
...@@ -236,7 +236,7 @@ export const receiveJobsForStageError = ({ commit }) => { ...@@ -236,7 +236,7 @@ export const receiveJobsForStageError = ({ commit }) => {
export const triggerManualJob = ({ state }, variables) => { export const triggerManualJob = ({ state }, variables) => {
const parsedVariables = variables.map(variable => { const parsedVariables = variables.map(variable => {
const copyVar = Object.assign({}, variable); const copyVar = { ...variable };
delete copyVar.id; delete copyVar.id;
return copyVar; return copyVar;
}); });
......
...@@ -270,8 +270,10 @@ export function getWebSocketUrl(path) { ...@@ -270,8 +270,10 @@ export function getWebSocketUrl(path) {
export function queryToObject(query) { export function queryToObject(query) {
const removeQuestionMarkFromQuery = String(query).startsWith('?') ? query.slice(1) : query; const removeQuestionMarkFromQuery = String(query).startsWith('?') ? query.slice(1) : query;
return removeQuestionMarkFromQuery.split('&').reduce((accumulator, curr) => { return removeQuestionMarkFromQuery.split('&').reduce((accumulator, curr) => {
const p = curr.split('='); const [key, value] = curr.split('=');
accumulator[decodeURIComponent(p[0])] = decodeURIComponent(p[1]); if (value !== undefined) {
accumulator[decodeURIComponent(key)] = decodeURIComponent(value);
}
return accumulator; return accumulator;
}, {}); }, {});
} }
......
<script>
import {
GlNewDropdown,
GlNewDropdownDivider,
GlNewDropdownHeader,
GlNewDropdownItem,
GlLoadingIcon,
GlSearchBoxByType,
GlIcon,
} from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import Api from '~/api';
import createFlash from '~/flash';
import { intersection, debounce } from 'lodash';
export default {
components: {
GlNewDropdown,
GlNewDropdownDivider,
GlNewDropdownHeader,
GlNewDropdownItem,
GlLoadingIcon,
GlSearchBoxByType,
GlIcon,
},
model: {
prop: 'preselectedMilestones',
event: 'change',
},
props: {
projectId: {
type: String,
required: true,
},
preselectedMilestones: {
type: Array,
default: () => [],
required: false,
},
extraLinks: {
type: Array,
default: () => [],
required: false,
},
},
data() {
return {
searchQuery: '',
projectMilestones: [],
searchResults: [],
selectedMilestones: [],
requestCount: 0,
};
},
translations: {
milestone: __('Milestone'),
selectMilestone: __('Select milestone'),
noMilestone: __('No milestone'),
noResultsLabel: __('No matching results'),
searchMilestones: __('Search Milestones'),
},
computed: {
selectedMilestonesLabel() {
if (this.milestoneTitles.length === 1) {
return this.milestoneTitles[0];
}
if (this.milestoneTitles.length > 1) {
const firstMilestoneName = this.milestoneTitles[0];
const numberOfOtherMilestones = this.milestoneTitles.length - 1;
return sprintf(__('%{firstMilestoneName} + %{numberOfOtherMilestones} more'), {
firstMilestoneName,
numberOfOtherMilestones,
});
}
return this.$options.translations.noMilestone;
},
milestoneTitles() {
return this.preselectedMilestones.map(milestone => milestone.title);
},
dropdownItems() {
return this.searchResults.length ? this.searchResults : this.projectMilestones;
},
noResults() {
return this.searchQuery.length > 2 && this.searchResults.length === 0;
},
isLoading() {
return this.requestCount !== 0;
},
},
mounted() {
this.fetchMilestones();
},
methods: {
fetchMilestones() {
this.requestCount += 1;
Api.projectMilestones(this.projectId)
.then(({ data }) => {
this.projectMilestones = this.getTitles(data);
this.selectedMilestones = intersection(this.projectMilestones, this.milestoneTitles);
})
.catch(() => {
createFlash(__('An error occurred while loading milestones'));
})
.finally(() => {
this.requestCount -= 1;
});
},
searchMilestones: debounce(function searchMilestones() {
this.requestCount += 1;
const options = {
search: this.searchQuery,
scope: 'milestones',
};
if (this.searchQuery.length < 3) {
this.requestCount -= 1;
this.searchResults = [];
return;
}
Api.projectSearch(this.projectId, options)
.then(({ data }) => {
const searchResults = this.getTitles(data);
this.searchResults = searchResults.length ? searchResults : [];
})
.catch(() => {
createFlash(__('An error occurred while searching for milestones'));
})
.finally(() => {
this.requestCount -= 1;
});
}, 100),
toggleMilestoneSelection(clickedMilestone) {
if (!clickedMilestone) return [];
let milestones = [...this.preselectedMilestones];
const hasMilestone = this.milestoneTitles.includes(clickedMilestone);
if (hasMilestone) {
milestones = milestones.filter(({ title }) => title !== clickedMilestone);
} else {
milestones.push({ title: clickedMilestone });
}
return milestones;
},
onMilestoneClicked(clickedMilestone) {
const milestones = this.toggleMilestoneSelection(clickedMilestone);
this.$emit('change', milestones);
this.selectedMilestones = intersection(
this.projectMilestones,
milestones.map(milestone => milestone.title),
);
},
isSelectedMilestone(milestoneTitle) {
return this.selectedMilestones.includes(milestoneTitle);
},
getTitles(milestones) {
return milestones.filter(({ state }) => state === 'active').map(({ title }) => title);
},
},
};
</script>
<template>
<gl-new-dropdown>
<template slot="button-content">
<span ref="buttonText" class="flex-grow-1 ml-1 text-muted">{{
selectedMilestonesLabel
}}</span>
<gl-icon name="chevron-down" />
</template>
<gl-new-dropdown-header>
<span class="text-center d-block">{{ $options.translations.selectMilestone }}</span>
</gl-new-dropdown-header>
<gl-new-dropdown-divider />
<gl-search-box-by-type
v-model.trim="searchQuery"
class="m-2"
:placeholder="this.$options.translations.searchMilestones"
@input="searchMilestones"
/>
<gl-new-dropdown-item @click="onMilestoneClicked(null)">
<span :class="{ 'pl-4': true, 'selected-item': selectedMilestones.length === 0 }">
{{ $options.translations.noMilestone }}
</span>
</gl-new-dropdown-item>
<gl-new-dropdown-divider />
<template v-if="isLoading">
<gl-loading-icon />
<gl-new-dropdown-divider />
</template>
<template v-else-if="noResults">
<div class="dropdown-item-space">
<span ref="noResults" class="pl-4">{{ $options.translations.noResultsLabel }}</span>
</div>
<gl-new-dropdown-divider />
</template>
<template v-else-if="dropdownItems.length">
<gl-new-dropdown-item
v-for="item in dropdownItems"
:key="item"
role="milestone option"
@click="onMilestoneClicked(item)"
>
<span :class="{ 'pl-4': true, 'selected-item': isSelectedMilestone(item) }">
{{ item }}
</span>
</gl-new-dropdown-item>
<gl-new-dropdown-divider />
</template>
<gl-new-dropdown-item v-for="(item, idx) in extraLinks" :key="idx" :href="item.url">
<span class="pl-4">{{ item.text }}</span>
</gl-new-dropdown-item>
</gl-new-dropdown>
</template>
...@@ -266,8 +266,17 @@ export default { ...@@ -266,8 +266,17 @@ export default {
); );
} }
}, },
expandedPanel: {
handler({ group, panel }) {
const dashboardPath = this.currentDashboard || this.firstDashboard.path;
updateHistory({
url: panelToUrl(dashboardPath, group, panel),
title: document.title,
});
},
deep: true,
},
}, },
created() { created() {
this.setInitialState({ this.setInitialState({
metricsEndpoint: this.metricsEndpoint, metricsEndpoint: this.metricsEndpoint,
......
...@@ -198,3 +198,21 @@ export const OPERATORS = { ...@@ -198,3 +198,21 @@ export const OPERATORS = {
equalTo: '==', equalTo: '==',
lessThan: '<', lessThan: '<',
}; };
/**
* Dashboard yml files support custom user-defined variables that
* are rendered as input elements in the monitoring dashboard.
* These values can be edited by the user and are passed on to the
* the backend and eventually to Prometheus API proxy.
*
* As of 13.0, the supported types are:
* simple custom -> dropdown elements
* advanced custom -> dropdown elements
* text -> text input elements
*
* Custom variables have a simple and a advanced variant.
*/
export const VARIABLE_TYPES = {
custom: 'custom',
text: 'text',
};
...@@ -51,6 +51,18 @@ const emptyStateFromError = error => { ...@@ -51,6 +51,18 @@ const emptyStateFromError = error => {
return metricStates.UNKNOWN_ERROR; return metricStates.UNKNOWN_ERROR;
}; };
/**
* Maps an variables object to an array
* @returns {Array} The custom variables array to be send to the API
* in the format of [variable1, variable1_value]
* @param {Object} variables - Custom variables provided by the user
*/
const transformVariablesObjectArray = variables =>
Object.entries(variables)
.flat()
.map(encodeURIComponent);
export default { export default {
/** /**
* Dashboard panels structure and global state * Dashboard panels structure and global state
......
import { VARIABLE_TYPES } from '../constants';
/**
* This file exclusively deals with parsing user-defined variables
* in dashboard yml file.
*
* As of 13.0, simple custom and advanced custom variables are supported.
*
* In the future iterations, text and query variables will be
* supported
*
*/
/**
* Utility method to determine if a custom variable is
* simple or not. If its not simple, it is advanced.
*
* @param {Array|Object} customVar Array if simple, object if advanced
* @returns {Boolean} true if simple, false if advanced
*/
const isSimpleCustomVariable = customVar => Array.isArray(customVar);
/**
* Normalize simple and advanced custom variable options to a standard
* format
* @param {Object} custom variable option
* @returns {Object} normalized custom variable options
*/
const normalizeDropdownOptions = ({ default: defaultOpt = false, text, value }) => ({
default: defaultOpt,
text,
value,
});
/**
* Simple custom variables have an array of values.
* This method parses such variables options to a standard format.
*
* @param {String} opt option from simple custom variable
*/
const parseSimpleDropdownOptions = opt => ({ text: opt, value: opt });
/**
* Custom advanced variables are rendered as dropdown elements in the dashboard
* header. This method parses advanced custom variables.
*
* @param {Object} advVariable advance custom variable
* @returns {Object}
*/
const customAdvancedVariableParser = advVariable => {
const options = advVariable?.options?.values ?? [];
return {
type: VARIABLE_TYPES.custom,
label: advVariable.label,
options: options.map(normalizeDropdownOptions),
};
};
/**
* Custom simple variables are rendered as dropdown elements in the dashboard
* header. This method parses simple custom variables.
*
* Simple custom variables do not have labels so its set to null here.
*
* @param {Array} customVariable array of options
* @returns {Object}
*/
const customSimpleVariableParser = simpleVar => {
const options = (simpleVar || []).map(parseSimpleDropdownOptions);
return {
type: VARIABLE_TYPES.custom,
label: null,
options: options.map(normalizeDropdownOptions),
};
};
/**
* This method returns a parser based on the type of the variable.
* Currently, the supported variables are simple custom and
* advanced custom only. In the future, this method will support
* text and query variables.
*
* @param {Array|Object} variable
* @return {Function} parser method
*/
const getVariableParser = variable => {
if (isSimpleCustomVariable(variable)) {
return customSimpleVariableParser;
} else if (variable.type === VARIABLE_TYPES.custom) {
return customAdvancedVariableParser;
}
return () => null;
};
/**
* This method parses the templating property in the dashboard yml file.
* The templating property has variables that are rendered as input elements
* for the user to edit. The values from input elements are relayed to
* backend and eventually Prometheus API.
*
* This method currently is not used anywhere. Once the issue
* https://gitlab.com/gitlab-org/gitlab/-/issues/214536 is completed,
* this method will have been used by the monitoring dashboard.
*
* @param {Object} templating templating variables from the dashboard yml file
* @returns {Object} a map of processed templating variables
*/
export const parseTemplatingVariables = ({ variables = {} } = {}) =>
Object.entries(variables).reduce((acc, [key, variable]) => {
// get the parser
const parser = getVariableParser(variable);
// parse the variable
const parsedVar = parser(variable);
// for simple custom variable label is null and it should be
// replace with key instead
if (parsedVar) {
acc[key] = {
...parsedVar,
label: parsedVar.label || key,
};
}
return acc;
}, {});
export default {};
import { omit, pickBy } from 'lodash'; import { omit } from 'lodash';
import { queryToObject, mergeUrlParams, removeParams } from '~/lib/utils/url_utility'; import { queryToObject, mergeUrlParams, removeParams } from '~/lib/utils/url_utility';
import { import {
timeRangeParamNames, timeRangeParamNames,
...@@ -196,25 +196,30 @@ export const expandedPanelPayloadFromUrl = (dashboard, search = window.location. ...@@ -196,25 +196,30 @@ export const expandedPanelPayloadFromUrl = (dashboard, search = window.location.
* Convert panel information to a URL for the user to * Convert panel information to a URL for the user to
* bookmark or share highlighting a specific panel. * bookmark or share highlighting a specific panel.
* *
* @param {String} dashboardPath - Dashboard path used as identifier * If no group/panel is set, the dashboard URL is returned.
* @param {String} group - Group Identifier *
* @param {?String} dashboard - Dashboard path, used as identifier for a dashboard
* @param {?String} group - Group Identifier
* @param {?Object} panel - Panel object from the dashboard * @param {?Object} panel - Panel object from the dashboard
* @param {?String} url - Base URL including current search params * @param {?String} url - Base URL including current search params
* @returns Dashboard URL which expands a panel (chart) * @returns Dashboard URL which expands a panel (chart)
*/ */
export const panelToUrl = (dashboardPath, group, panel, url = window.location.href) => { export const panelToUrl = (dashboard = null, group, panel, url = window.location.href) => {
if (!group || !panel) { const params = {
return null; dashboard,
};
if (group && panel) {
params.group = group;
params.title = panel.title;
params.y_label = panel.y_label;
} else {
// Remove existing parameters if any
params.group = null;
params.title = null;
params.y_label = null;
} }
const params = pickBy(
{
dashboard: dashboardPath,
group,
title: panel.title,
y_label: panel.y_label,
},
value => value != null,
);
return mergeUrlParams(params, url); return mergeUrlParams(params, url);
}; };
......
...@@ -15,6 +15,19 @@ export default () => { ...@@ -15,6 +15,19 @@ export default () => {
notesApp, notesApp,
}, },
store, store,
data() {
const notesDataset = document.getElementById('js-vue-mr-discussions').dataset;
const noteableData = JSON.parse(notesDataset.noteableData);
noteableData.noteableType = notesDataset.noteableType;
noteableData.targetType = notesDataset.targetType;
return {
noteableData,
currentUserData: JSON.parse(notesDataset.currentUserData),
notesData: JSON.parse(notesDataset.notesData),
helpPagePath: notesDataset.helpPagePath,
};
},
computed: { computed: {
...mapGetters(['discussionTabCounter']), ...mapGetters(['discussionTabCounter']),
...mapState({ ...mapState({
...@@ -54,19 +67,6 @@ export default () => { ...@@ -54,19 +67,6 @@ export default () => {
updateDiscussionTabCounter() { updateDiscussionTabCounter() {
this.notesCountBadge.text(this.discussionTabCounter); this.notesCountBadge.text(this.discussionTabCounter);
}, },
dataset() {
const data = this.$el.dataset;
const noteableData = JSON.parse(data.noteableData);
noteableData.noteableType = data.noteableType;
noteableData.targetType = data.targetType;
return {
noteableData,
notesData: JSON.parse(data.notesData),
userData: JSON.parse(data.currentUserData),
helpPagePath: data.helpPagePath,
};
},
}, },
render(createElement) { render(createElement) {
// NOTE: Even though `discussionKeyboardNavigator` is added to the `notes-app`, // NOTE: Even though `discussionKeyboardNavigator` is added to the `notes-app`,
...@@ -76,8 +76,11 @@ export default () => { ...@@ -76,8 +76,11 @@ export default () => {
return createElement(discussionKeyboardNavigator, [ return createElement(discussionKeyboardNavigator, [
createElement('notes-app', { createElement('notes-app', {
props: { props: {
...this.dataset(), noteableData: this.noteableData,
notesData: this.notesData,
userData: this.currentUserData,
shouldShow: this.isShowTabActive, shouldShow: this.isShowTabActive,
helpPagePath: this.helpPagePath,
}, },
}), }),
]); ]);
......
...@@ -3,7 +3,15 @@ import $ from 'jquery'; ...@@ -3,7 +3,15 @@ import $ from 'jquery';
import { mapActions, mapGetters, mapState } from 'vuex'; import { mapActions, mapGetters, mapState } from 'vuex';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import Autosize from 'autosize'; import Autosize from 'autosize';
import { GlAlert, GlIntersperse, GlLink, GlSprintf } from '@gitlab/ui'; import {
GlAlert,
GlFormCheckbox,
GlIcon,
GlIntersperse,
GlLink,
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import Flash from '../../flash'; import Flash from '../../flash';
...@@ -24,6 +32,7 @@ import loadingButton from '../../vue_shared/components/loading_button.vue'; ...@@ -24,6 +32,7 @@ import loadingButton from '../../vue_shared/components/loading_button.vue';
import noteSignedOutWidget from './note_signed_out_widget.vue'; import noteSignedOutWidget from './note_signed_out_widget.vue';
import discussionLockedWidget from './discussion_locked_widget.vue'; import discussionLockedWidget from './discussion_locked_widget.vue';
import issuableStateMixin from '../mixins/issuable_state'; import issuableStateMixin from '../mixins/issuable_state';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default { export default {
name: 'CommentForm', name: 'CommentForm',
...@@ -36,11 +45,16 @@ export default { ...@@ -36,11 +45,16 @@ export default {
loadingButton, loadingButton,
TimelineEntryItem, TimelineEntryItem,
GlAlert, GlAlert,
GlFormCheckbox,
GlIcon,
GlIntersperse, GlIntersperse,
GlLink, GlLink,
GlSprintf, GlSprintf,
}, },
mixins: [issuableStateMixin], directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [issuableStateMixin, glFeatureFlagsMixin()],
props: { props: {
noteableType: { noteableType: {
type: String, type: String,
...@@ -51,6 +65,7 @@ export default { ...@@ -51,6 +65,7 @@ export default {
return { return {
note: '', note: '',
noteType: constants.COMMENT, noteType: constants.COMMENT,
noteIsConfidential: false,
isSubmitting: false, isSubmitting: false,
isSubmitButtonDisabled: true, isSubmitButtonDisabled: true,
}; };
...@@ -138,6 +153,9 @@ export default { ...@@ -138,6 +153,9 @@ export default {
trackingLabel() { trackingLabel() {
return slugifyWithUnderscore(`${this.commentButtonTitle} button`); return slugifyWithUnderscore(`${this.commentButtonTitle} button`);
}, },
confidentialNotesEnabled() {
return Boolean(this.glFeatures.confidentialNotes);
},
}, },
watch: { watch: {
note(newNote) { note(newNote) {
...@@ -185,6 +203,7 @@ export default { ...@@ -185,6 +203,7 @@ export default {
note: { note: {
noteable_type: this.noteableType, noteable_type: this.noteableType,
noteable_id: this.getNoteableData.id, noteable_id: this.getNoteableData.id,
confidential: this.noteIsConfidential,
note: this.note, note: this.note,
}, },
merge_request_diff_head_sha: this.getNoteableData.diff_head_sha, merge_request_diff_head_sha: this.getNoteableData.diff_head_sha,
...@@ -285,6 +304,7 @@ export default { ...@@ -285,6 +304,7 @@ export default {
if (shouldClear) { if (shouldClear) {
this.note = ''; this.note = '';
this.noteIsConfidential = false;
this.resizeTextarea(); this.resizeTextarea();
this.$refs.markdownField.previewMarkdown = false; this.$refs.markdownField.previewMarkdown = false;
} }
...@@ -411,6 +431,19 @@ js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input" ...@@ -411,6 +431,19 @@ js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input"
</p> </p>
</gl-alert> </gl-alert>
<div class="note-form-actions"> <div class="note-form-actions">
<div v-if="confidentialNotesEnabled" class="js-confidential-note-toggle mb-4">
<gl-form-checkbox v-model="noteIsConfidential">
<gl-icon name="eye-slash" :size="12" />
{{ __('Mark this comment as private') }}
<gl-icon
v-gl-tooltip:tooltipcontainer.bottom
name="question"
:size="12"
:title="__('Private comments are accessible by internal staff only')"
class="gl-text-gray-800"
/>
</gl-form-checkbox>
</div>
<div <div
class="float-left btn-group class="float-left btn-group
append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
......
<script> <script>
import { mapActions } from 'vuex'; import { mapActions } from 'vuex';
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import GitlabTeamMemberBadge from '~/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue'; import GitlabTeamMemberBadge from '~/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue';
...@@ -7,6 +8,10 @@ export default { ...@@ -7,6 +8,10 @@ export default {
components: { components: {
timeAgoTooltip, timeAgoTooltip,
GitlabTeamMemberBadge, GitlabTeamMemberBadge,
GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
}, },
props: { props: {
author: { author: {
...@@ -44,6 +49,11 @@ export default { ...@@ -44,6 +49,11 @@ export default {
required: false, required: false,
default: true, default: true,
}, },
isConfidential: {
type: Boolean,
required: false,
default: false,
},
}, },
data() { data() {
return { return {
...@@ -160,7 +170,7 @@ export default { ...@@ -160,7 +170,7 @@ export default {
</span> </span>
</template> </template>
<span v-else>{{ __('A deleted user') }}</span> <span v-else>{{ __('A deleted user') }}</span>
<span class="note-headline-light note-headline-meta"> <span class="note-headline-light note-headline-meta d-inline-flex align-items-center">
<span class="system-note-message"> <slot></slot> </span> <span class="system-note-message"> <slot></slot> </span>
<template v-if="createdAt"> <template v-if="createdAt">
<span ref="actionText" class="system-note-separator"> <span ref="actionText" class="system-note-separator">
...@@ -177,6 +187,15 @@ export default { ...@@ -177,6 +187,15 @@ export default {
</a> </a>
<time-ago-tooltip v-else ref="noteTimestamp" :time="createdAt" tooltip-placement="bottom" /> <time-ago-tooltip v-else ref="noteTimestamp" :time="createdAt" tooltip-placement="bottom" />
</template> </template>
<gl-icon
v-if="isConfidential"
ref="confidentialIndicator"
v-gl-tooltip:tooltipcontainer.bottom
name="eye-slash"
:size="14"
:title="__('Private comments are accessible by internal staff only')"
class="ml-1 gl-text-gray-800"
/>
<slot name="extra-controls"></slot> <slot name="extra-controls"></slot>
<i <i
v-if="showSpinner" v-if="showSpinner"
......
...@@ -255,10 +255,16 @@ export default { ...@@ -255,10 +255,16 @@ export default {
</div> </div>
<div class="timeline-content"> <div class="timeline-content">
<div class="note-header"> <div class="note-header">
<note-header v-once :author="author" :created-at="note.created_at" :note-id="note.id"> <note-header
v-once
:author="author"
:created-at="note.created_at"
:note-id="note.id"
:is-confidential="note.confidential"
>
<slot slot="note-header-info" name="note-header-info"></slot> <slot slot="note-header-info" name="note-header-info"></slot>
<span v-if="commit" v-html="actionText"></span> <span v-if="commit" v-html="actionText"></span>
<span v-else class="d-none d-sm-inline">&middot;</span> <span v-else class="d-none d-sm-inline mr-1">&middot;</span>
</note-header> </note-header>
<note-actions <note-actions
:author-id="author.id" :author-id="author.id"
......
...@@ -230,10 +230,11 @@ export default { ...@@ -230,10 +230,11 @@ export default {
const defaultConfig = { path: this.getNotesDataByProp('discussionsPath') }; const defaultConfig = { path: this.getNotesDataByProp('discussionsPath') };
if (doesHashExistInUrl(constants.NOTE_UNDERSCORE)) { if (doesHashExistInUrl(constants.NOTE_UNDERSCORE)) {
return Object.assign({}, defaultConfig, { return {
...defaultConfig,
filter: constants.DISCUSSION_FILTERS_DEFAULT_VALUE, filter: constants.DISCUSSION_FILTERS_DEFAULT_VALUE,
persistFilter: false, persistFilter: false,
}); };
} }
return defaultConfig; return defaultConfig;
}, },
......
...@@ -2,11 +2,9 @@ import Vue from 'vue'; ...@@ -2,11 +2,9 @@ import Vue from 'vue';
import notesApp from './components/notes_app.vue'; import notesApp from './components/notes_app.vue';
import initDiscussionFilters from './discussion_filters'; import initDiscussionFilters from './discussion_filters';
import initSortDiscussions from './sort_discussions'; import initSortDiscussions from './sort_discussions';
import createStore from './stores'; import { store } from './stores';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const store = createStore();
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el: '#js-vue-notes', el: '#js-vue-notes',
...@@ -14,9 +12,8 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -14,9 +12,8 @@ document.addEventListener('DOMContentLoaded', () => {
notesApp, notesApp,
}, },
store, store,
methods: { data() {
setData() { const notesDataset = document.getElementById('js-vue-notes').dataset;
const notesDataset = this.$el.dataset;
const parsedUserData = JSON.parse(notesDataset.currentUserData); const parsedUserData = JSON.parse(notesDataset.currentUserData);
const noteableData = JSON.parse(notesDataset.noteableData); const noteableData = JSON.parse(notesDataset.noteableData);
let currentUserData = {}; let currentUserData = {};
...@@ -36,14 +33,17 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -36,14 +33,17 @@ document.addEventListener('DOMContentLoaded', () => {
return { return {
noteableData, noteableData,
userData: currentUserData, currentUserData,
notesData: JSON.parse(notesDataset.notesData), notesData: JSON.parse(notesDataset.notesData),
}; };
}, },
},
render(createElement) { render(createElement) {
return createElement('notes-app', { return createElement('notes-app', {
props: { ...this.setData() }, props: {
noteableData: this.noteableData,
notesData: this.notesData,
userData: this.currentUserData,
},
}); });
}, },
}); });
......
...@@ -248,7 +248,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => { ...@@ -248,7 +248,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
const hasQuickActions = utils.hasQuickActions(placeholderText); const hasQuickActions = utils.hasQuickActions(placeholderText);
const replyId = noteData.data.in_reply_to_discussion_id; const replyId = noteData.data.in_reply_to_discussion_id;
let methodToDispatch; let methodToDispatch;
const postData = Object.assign({}, noteData); const postData = { ...noteData };
if (postData.isDraft === true) { if (postData.isDraft === true) {
methodToDispatch = replyId methodToDispatch = replyId
? 'batchComments/addDraftToDiscussion' ? 'batchComments/addDraftToDiscussion'
......
...@@ -4,4 +4,8 @@ import notesModule from './modules'; ...@@ -4,4 +4,8 @@ import notesModule from './modules';
Vue.use(Vuex); Vue.use(Vuex);
export default () => new Vuex.Store(notesModule()); // NOTE: Giving the option to either use a singleton or new instance of notes.
const notesStore = () => new Vuex.Store(notesModule());
export default notesStore;
export const store = notesStore();
...@@ -25,6 +25,7 @@ export default () => ({ ...@@ -25,6 +25,7 @@ export default () => ({
}, },
userData: {}, userData: {},
noteableData: { noteableData: {
confidential: false, // TODO: Move data like this to Issue Store, should not be apart of notes.
current_user: {}, current_user: {},
preview_note_path: 'path/to/preview', preview_note_path: 'path/to/preview',
}, },
......
...@@ -10,7 +10,6 @@ import { __ } from '~/locale'; ...@@ -10,7 +10,6 @@ import { __ } from '~/locale';
import PipelinesStore from '../../../../pipelines/stores/pipelines_store'; import PipelinesStore from '../../../../pipelines/stores/pipelines_store';
import pipelinesComponent from '../../../../pipelines/components/pipelines.vue'; import pipelinesComponent from '../../../../pipelines/components/pipelines.vue';
import Translate from '../../../../vue_shared/translate'; import Translate from '../../../../vue_shared/translate';
import initVueAlerts from '../../../../vue_alerts';
Vue.use(Translate); Vue.use(Translate);
Vue.use(GlToast); Vue.use(GlToast);
...@@ -57,5 +56,3 @@ document.addEventListener( ...@@ -57,5 +56,3 @@ document.addEventListener(
}, },
}), }),
); );
document.addEventListener('DOMContentLoaded', initVueAlerts);
...@@ -12,10 +12,8 @@ import initReadMore from '~/read_more'; ...@@ -12,10 +12,8 @@ import initReadMore from '~/read_more';
import leaveByUrl from '~/namespaces/leave_by_url'; import leaveByUrl from '~/namespaces/leave_by_url';
import Star from '../../../star'; import Star from '../../../star';
import notificationsDropdown from '../../../notifications_dropdown'; import notificationsDropdown from '../../../notifications_dropdown';
import initVueAlerts from '../../../vue_alerts';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initVueAlerts();
initReadMore(); initReadMore();
new Star(); // eslint-disable-line no-new new Star(); // eslint-disable-line no-new
notificationsDropdown(); notificationsDropdown();
......
...@@ -99,9 +99,10 @@ export default { ...@@ -99,9 +99,10 @@ export default {
// 3. If GitLab user does not have avatar, they might have a Gravatar // 3. If GitLab user does not have avatar, they might have a Gravatar
} else if (this.pipeline.commit.author_gravatar_url) { } else if (this.pipeline.commit.author_gravatar_url) {
commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, { commitAuthorInformation = {
...this.pipeline.commit.author,
avatar_url: this.pipeline.commit.author_gravatar_url, avatar_url: this.pipeline.commit.author_gravatar_url,
}); };
} }
// 4. If committer is not a GitLab User, they can have a Gravatar // 4. If committer is not a GitLab User, they can have a Gravatar
} else { } else {
......
...@@ -29,7 +29,14 @@ export default { ...@@ -29,7 +29,14 @@ export default {
successPercentage() { successPercentage() {
// Returns a full number when the decimals equal .00. // Returns a full number when the decimals equal .00.
// Otherwise returns a float to two decimal points // Otherwise returns a float to two decimal points
return Number(((this.report.success_count / this.report.total_count) * 100 || 0).toFixed(2)); // Do not include skipped tests as part of the total when doing success calculations.
const totalCompletedCount = this.report.total_count - this.report.skipped_count;
if (totalCompletedCount > 0) {
return Number(((this.report.success_count / totalCompletedCount) * 100 || 0).toFixed(2));
}
return 0;
}, },
formattedDuration() { formattedDuration() {
return formatTime(secondsToMilliseconds(this.report.total_time)); return formatTime(secondsToMilliseconds(this.report.total_time));
......
...@@ -15,7 +15,7 @@ export default class PipelineStore { ...@@ -15,7 +15,7 @@ export default class PipelineStore {
* @param {Object} pipeline * @param {Object} pipeline
*/ */
storePipeline(pipeline = {}) { storePipeline(pipeline = {}) {
const pipelineCopy = Object.assign({}, pipeline); const pipelineCopy = { ...pipeline };
if (pipelineCopy.triggered_by) { if (pipelineCopy.triggered_by) {
pipelineCopy.triggered_by = [pipelineCopy.triggered_by]; pipelineCopy.triggered_by = [pipelineCopy.triggered_by];
......
...@@ -21,7 +21,7 @@ export default { ...@@ -21,7 +21,7 @@ export default {
state.original = Object.freeze(settings); state.original = Object.freeze(settings);
}, },
[types.RESET_SETTINGS](state) { [types.RESET_SETTINGS](state) {
state.settings = Object.assign({}, state.original); state.settings = { ...state.original };
}, },
[types.TOGGLE_LOADING](state) { [types.TOGGLE_LOADING](state) {
state.isLoading = !state.isLoading; state.isLoading = !state.isLoading;
......
...@@ -41,5 +41,5 @@ export const NAME_REGEX_KEEP_LABEL = s__( ...@@ -41,5 +41,5 @@ export const NAME_REGEX_KEEP_LABEL = s__(
); );
export const NAME_REGEX_KEEP_PLACEHOLDER = ''; export const NAME_REGEX_KEEP_PLACEHOLDER = '';
export const NAME_REGEX_KEEP_DESCRIPTION = s__( export const NAME_REGEX_KEEP_DESCRIPTION = s__(
'ContainerRegistry|Regular expressions such as %{codeStart}.*-test%{codeEnd} or %{codeStart}dev-.*%{codeEnd} are supported', 'ContainerRegistry|Regular expressions such as %{codeStart}.*-master%{codeEnd} or %{codeStart}release-.*%{codeEnd} are supported',
); );
...@@ -9,6 +9,7 @@ import { BACK_URL_PARAM } from '~/releases/constants'; ...@@ -9,6 +9,7 @@ import { BACK_URL_PARAM } from '~/releases/constants';
import { getParameterByName } from '~/lib/utils/common_utils'; import { getParameterByName } from '~/lib/utils/common_utils';
import AssetLinksForm from './asset_links_form.vue'; import AssetLinksForm from './asset_links_form.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue';
export default { export default {
name: 'ReleaseEditApp', name: 'ReleaseEditApp',
...@@ -18,6 +19,7 @@ export default { ...@@ -18,6 +19,7 @@ export default {
GlButton, GlButton,
MarkdownField, MarkdownField,
AssetLinksForm, AssetLinksForm,
MilestoneCombobox,
}, },
directives: { directives: {
autofocusonshow, autofocusonshow,
...@@ -32,6 +34,10 @@ export default { ...@@ -32,6 +34,10 @@ export default {
'markdownPreviewPath', 'markdownPreviewPath',
'releasesPagePath', 'releasesPagePath',
'updateReleaseApiDocsPath', 'updateReleaseApiDocsPath',
'release',
'newMilestonePath',
'manageMilestonesPath',
'projectId',
]), ]),
...mapGetters('detail', ['isValid']), ...mapGetters('detail', ['isValid']),
showForm() { showForm() {
...@@ -82,6 +88,14 @@ export default { ...@@ -82,6 +88,14 @@ export default {
this.updateReleaseNotes(notes); this.updateReleaseNotes(notes);
}, },
}, },
releaseMilestones: {
get() {
return this.$store.state.detail.release.milestones;
},
set(milestones) {
this.updateReleaseMilestones(milestones);
},
},
cancelPath() { cancelPath() {
return getParameterByName(BACK_URL_PARAM) || this.releasesPagePath; return getParameterByName(BACK_URL_PARAM) || this.releasesPagePath;
}, },
...@@ -91,6 +105,18 @@ export default { ...@@ -91,6 +105,18 @@ export default {
isSaveChangesDisabled() { isSaveChangesDisabled() {
return this.isUpdatingRelease || !this.isValid; return this.isUpdatingRelease || !this.isValid;
}, },
milestoneComboboxExtraLinks() {
return [
{
text: __('Create new'),
url: this.newMilestonePath,
},
{
text: __('Manage milestones'),
url: this.manageMilestonesPath,
},
];
},
}, },
created() { created() {
this.fetchRelease(); this.fetchRelease();
...@@ -101,6 +127,7 @@ export default { ...@@ -101,6 +127,7 @@ export default {
'updateRelease', 'updateRelease',
'updateReleaseTitle', 'updateReleaseTitle',
'updateReleaseNotes', 'updateReleaseNotes',
'updateReleaseMilestones',
]), ]),
}, },
}; };
...@@ -137,6 +164,16 @@ export default { ...@@ -137,6 +164,16 @@ export default {
class="form-control" class="form-control"
/> />
</gl-form-group> </gl-form-group>
<gl-form-group class="w-50">
<label>{{ __('Milestones') }}</label>
<div class="d-flex flex-column col-md-6 col-sm-10 pl-0">
<milestone-combobox
v-model="releaseMilestones"
:project-id="projectId"
:extra-links="milestoneComboboxExtraLinks"
/>
</div>
</gl-form-group>
<gl-form-group> <gl-form-group>
<label for="release-notes">{{ __('Release notes') }}</label> <label for="release-notes">{{ __('Release notes') }}</label>
<div class="bordered-box pr-3 pl-3"> <div class="bordered-box pr-3 pl-3">
...@@ -158,8 +195,7 @@ export default { ...@@ -158,8 +195,7 @@ export default {
:placeholder="__('Write your release notes or drag your files here…')" :placeholder="__('Write your release notes or drag your files here…')"
@keydown.meta.enter="updateRelease()" @keydown.meta.enter="updateRelease()"
@keydown.ctrl.enter="updateRelease()" @keydown.ctrl.enter="updateRelease()"
> ></textarea>
</textarea>
</markdown-field> </markdown-field>
</div> </div>
</gl-form-group> </gl-form-group>
...@@ -174,12 +210,9 @@ export default { ...@@ -174,12 +210,9 @@ export default {
type="submit" type="submit"
:aria-label="__('Save changes')" :aria-label="__('Save changes')"
:disabled="isSaveChangesDisabled" :disabled="isSaveChangesDisabled"
>{{ __('Save changes') }}</gl-button
> >
{{ __('Save changes') }} <gl-button :href="cancelPath" class="js-cancel-button">{{ __('Cancel') }}</gl-button>
</gl-button>
<gl-button :href="cancelPath" class="js-cancel-button">
{{ __('Cancel') }}
</gl-button>
</div> </div>
</form> </form>
</div> </div>
......
<script> <script>
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { GlSkeletonLoading, GlEmptyState, GlLink } from '@gitlab/ui'; import { GlSkeletonLoading, GlEmptyState, GlLink, GlButton } from '@gitlab/ui';
import { import {
getParameterByName, getParameterByName,
historyPushState, historyPushState,
...@@ -18,6 +18,7 @@ export default { ...@@ -18,6 +18,7 @@ export default {
ReleaseBlock, ReleaseBlock,
TablePagination, TablePagination,
GlLink, GlLink,
GlButton,
}, },
props: { props: {
projectId: { projectId: {
...@@ -69,14 +70,16 @@ export default { ...@@ -69,14 +70,16 @@ export default {
</script> </script>
<template> <template>
<div class="flex flex-column mt-2"> <div class="flex flex-column mt-2">
<gl-link <gl-button
v-if="newReleasePath" v-if="newReleasePath"
:href="newReleasePath" :href="newReleasePath"
:aria-describedby="shouldRenderEmptyState && 'releases-description'" :aria-describedby="shouldRenderEmptyState && 'releases-description'"
class="btn btn-success align-self-end mb-2 js-new-release-btn" category="primary"
variant="success"
class="align-self-end mb-2 js-new-release-btn"
> >
{{ __('New release') }} {{ __('New release') }}
</gl-link> </gl-button>
<gl-skeleton-loading v-if="isLoading" class="js-loading" /> <gl-skeleton-loading v-if="isLoading" class="js-loading" />
......
<script> <script>
import { GlTooltipDirective, GlLink, GlBadge } from '@gitlab/ui'; import { GlTooltipDirective, GlLink, GlBadge, GlButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import { BACK_URL_PARAM } from '~/releases/constants'; import { BACK_URL_PARAM } from '~/releases/constants';
import { setUrlParams } from '~/lib/utils/url_utility'; import { setUrlParams } from '~/lib/utils/url_utility';
...@@ -10,6 +10,7 @@ export default { ...@@ -10,6 +10,7 @@ export default {
GlLink, GlLink,
GlBadge, GlBadge,
Icon, Icon,
GlButton,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -50,14 +51,16 @@ export default { ...@@ -50,14 +51,16 @@ export default {
__('Upcoming Release') __('Upcoming Release')
}}</gl-badge> }}</gl-badge>
</h2> </h2>
<gl-link <gl-button
v-if="editLink" v-if="editLink"
v-gl-tooltip v-gl-tooltip
class="btn btn-default append-right-10 js-edit-button ml-2" category="primary"
variant="default"
class="append-right-10 js-edit-button ml-2 pb-2"
:title="__('Edit this release')" :title="__('Edit this release')"
:href="editLink" :href="editLink"
> >
<icon name="pencil" /> <icon name="pencil" />
</gl-link> </gl-button>
</div> </div>
</template> </template>
...@@ -18,7 +18,12 @@ export const fetchRelease = ({ dispatch, state }) => { ...@@ -18,7 +18,12 @@ export const fetchRelease = ({ dispatch, state }) => {
return api return api
.release(state.projectId, state.tagName) .release(state.projectId, state.tagName)
.then(({ data: release }) => { .then(({ data }) => {
const release = {
...data,
milestones: data.milestones || [],
};
dispatch('receiveReleaseSuccess', convertObjectPropsToCamelCase(release, { deep: true })); dispatch('receiveReleaseSuccess', convertObjectPropsToCamelCase(release, { deep: true }));
}) })
.catch(error => { .catch(error => {
...@@ -28,6 +33,8 @@ export const fetchRelease = ({ dispatch, state }) => { ...@@ -28,6 +33,8 @@ export const fetchRelease = ({ dispatch, state }) => {
export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_RELEASE_TITLE, title); export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_RELEASE_TITLE, title);
export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_RELEASE_NOTES, notes); export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_RELEASE_NOTES, notes);
export const updateReleaseMilestones = ({ commit }, milestones) =>
commit(types.UPDATE_RELEASE_MILESTONES, milestones);
export const requestUpdateRelease = ({ commit }) => commit(types.REQUEST_UPDATE_RELEASE); export const requestUpdateRelease = ({ commit }) => commit(types.REQUEST_UPDATE_RELEASE);
export const receiveUpdateReleaseSuccess = ({ commit, state, rootState }) => { export const receiveUpdateReleaseSuccess = ({ commit, state, rootState }) => {
...@@ -45,12 +52,14 @@ export const updateRelease = ({ dispatch, state, getters }) => { ...@@ -45,12 +52,14 @@ export const updateRelease = ({ dispatch, state, getters }) => {
dispatch('requestUpdateRelease'); dispatch('requestUpdateRelease');
const { release } = state; const { release } = state;
const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : [];
return ( return (
api api
.updateRelease(state.projectId, state.tagName, { .updateRelease(state.projectId, state.tagName, {
name: release.name, name: release.name,
description: release.description, description: release.description,
milestones,
}) })
/** /**
......
...@@ -4,6 +4,7 @@ export const RECEIVE_RELEASE_ERROR = 'RECEIVE_RELEASE_ERROR'; ...@@ -4,6 +4,7 @@ export const RECEIVE_RELEASE_ERROR = 'RECEIVE_RELEASE_ERROR';
export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE'; export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE';
export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES'; export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES';
export const UPDATE_RELEASE_MILESTONES = 'UPDATE_RELEASE_MILESTONES';
export const REQUEST_UPDATE_RELEASE = 'REQUEST_UPDATE_RELEASE'; export const REQUEST_UPDATE_RELEASE = 'REQUEST_UPDATE_RELEASE';
export const RECEIVE_UPDATE_RELEASE_SUCCESS = 'RECEIVE_UPDATE_RELEASE_SUCCESS'; export const RECEIVE_UPDATE_RELEASE_SUCCESS = 'RECEIVE_UPDATE_RELEASE_SUCCESS';
......
...@@ -28,6 +28,10 @@ export default { ...@@ -28,6 +28,10 @@ export default {
state.release.description = notes; state.release.description = notes;
}, },
[types.UPDATE_RELEASE_MILESTONES](state, milestones) {
state.release.milestones = milestones;
},
[types.REQUEST_UPDATE_RELEASE](state) { [types.REQUEST_UPDATE_RELEASE](state) {
state.isUpdatingRelease = true; state.isUpdatingRelease = true;
}, },
......
...@@ -6,6 +6,8 @@ export default ({ ...@@ -6,6 +6,8 @@ export default ({
markdownPreviewPath, markdownPreviewPath,
updateReleaseApiDocsPath, updateReleaseApiDocsPath,
releaseAssetsDocsPath, releaseAssetsDocsPath,
manageMilestonesPath,
newMilestonePath,
}) => ({ }) => ({
projectId, projectId,
tagName, tagName,
...@@ -14,6 +16,8 @@ export default ({ ...@@ -14,6 +16,8 @@ export default ({
markdownPreviewPath, markdownPreviewPath,
updateReleaseApiDocsPath, updateReleaseApiDocsPath,
releaseAssetsDocsPath, releaseAssetsDocsPath,
manageMilestonesPath,
newMilestonePath,
/** The Release object */ /** The Release object */
release: null, release: null,
......
...@@ -4,6 +4,7 @@ import query from '~/issuable_sidebar/queries/issue_sidebar.query.graphql'; ...@@ -4,6 +4,7 @@ import query from '~/issuable_sidebar/queries/issue_sidebar.query.graphql';
import actionCable from '~/actioncable_consumer'; import actionCable from '~/actioncable_consumer';
export default { export default {
subscription: null,
name: 'AssigneesRealtime', name: 'AssigneesRealtime',
props: { props: {
mediator: { mediator: {
...@@ -36,6 +37,9 @@ export default { ...@@ -36,6 +37,9 @@ export default {
mounted() { mounted() {
this.initActionCablePolling(); this.initActionCablePolling();
}, },
beforeDestroy() {
this.$options.subscription.unsubscribe();
},
methods: { methods: {
received(data) { received(data) {
if (data.event === 'updated') { if (data.event === 'updated') {
...@@ -43,7 +47,7 @@ export default { ...@@ -43,7 +47,7 @@ export default {
} }
}, },
initActionCablePolling() { initActionCablePolling() {
actionCable.subscriptions.create( this.$options.subscription = actionCable.subscriptions.create(
{ {
channel: 'IssuesChannel', channel: 'IssuesChannel',
project_path: this.projectPath, project_path: this.projectPath,
......
<script> <script>
import { mapState } from 'vuex';
import { __ } from '~/locale'; import { __ } from '~/locale';
import Flash from '~/flash'; import Flash from '~/flash';
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '~/sidebar/event_hub'; import eventHub from '~/sidebar/event_hub';
import editForm from './edit_form.vue'; import EditForm from './edit_form.vue';
import recaptchaModalImplementor from '~/vue_shared/mixins/recaptcha_modal_implementor'; import recaptchaModalImplementor from '~/vue_shared/mixins/recaptcha_modal_implementor';
export default { export default {
components: { components: {
editForm, EditForm,
Icon, Icon,
}, },
directives: { directives: {
...@@ -17,10 +18,6 @@ export default { ...@@ -17,10 +18,6 @@ export default {
}, },
mixins: [recaptchaModalImplementor], mixins: [recaptchaModalImplementor],
props: { props: {
isConfidential: {
required: true,
type: Boolean,
},
isEditable: { isEditable: {
required: true, required: true,
type: Boolean, type: Boolean,
...@@ -36,11 +33,12 @@ export default { ...@@ -36,11 +33,12 @@ export default {
}; };
}, },
computed: { computed: {
...mapState({ confidential: ({ noteableData }) => noteableData.confidential }),
confidentialityIcon() { confidentialityIcon() {
return this.isConfidential ? 'eye-slash' : 'eye'; return this.confidential ? 'eye-slash' : 'eye';
}, },
tooltipLabel() { tooltipLabel() {
return this.isConfidential ? __('Confidential') : __('Not confidential'); return this.confidential ? __('Confidential') : __('Not confidential');
}, },
}, },
created() { created() {
...@@ -95,17 +93,16 @@ export default { ...@@ -95,17 +93,16 @@ export default {
data-track-label="right_sidebar" data-track-label="right_sidebar"
data-track-property="confidentiality" data-track-property="confidentiality"
@click.prevent="toggleForm" @click.prevent="toggleForm"
>{{ __('Edit') }}</a
> >
{{ __('Edit') }}
</a>
</div> </div>
<div class="value sidebar-item-value hide-collapsed"> <div class="value sidebar-item-value hide-collapsed">
<editForm <edit-form
v-if="edit" v-if="edit"
:is-confidential="isConfidential" :is-confidential="confidential"
:update-confidential-attribute="updateConfidentialAttribute" :update-confidential-attribute="updateConfidentialAttribute"
/> />
<div v-if="!isConfidential" class="no-value sidebar-item-value"> <div v-if="!confidential" class="no-value sidebar-item-value">
<icon :size="16" name="eye" aria-hidden="true" class="sidebar-item-icon inline" /> <icon :size="16" name="eye" aria-hidden="true" class="sidebar-item-icon inline" />
{{ __('Not confidential') }} {{ __('Not confidential') }}
</div> </div>
......
...@@ -10,6 +10,7 @@ import sidebarParticipants from './components/participants/sidebar_participants. ...@@ -10,6 +10,7 @@ import sidebarParticipants from './components/participants/sidebar_participants.
import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue'; import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue';
import Translate from '../vue_shared/translate'; import Translate from '../vue_shared/translate';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import { store } from '~/notes/stores';
Vue.use(Translate); Vue.use(Translate);
Vue.use(VueApollo); Vue.use(VueApollo);
...@@ -59,8 +60,8 @@ function mountConfidentialComponent(mediator) { ...@@ -59,8 +60,8 @@ function mountConfidentialComponent(mediator) {
const ConfidentialComp = Vue.extend(ConfidentialIssueSidebar); const ConfidentialComp = Vue.extend(ConfidentialIssueSidebar);
new ConfidentialComp({ new ConfidentialComp({
store,
propsData: { propsData: {
isConfidential: initialData.is_confidential,
isEditable: initialData.is_editable, isEditable: initialData.is_editable,
service: mediator.service, service: mediator.service,
}, },
......
extend type Query { extend type Query {
isSupportedContent: Boolean! isSupportedContent: Boolean!
projectId: String! project: String!
returnUrl: String returnUrl: String
sourcePath: String! sourcePath: String!
username: String! username: String!
......
...@@ -6,7 +6,14 @@ import createRouter from './router'; ...@@ -6,7 +6,14 @@ import createRouter from './router';
import createApolloProvider from './graphql'; import createApolloProvider from './graphql';
const initStaticSiteEditor = el => { const initStaticSiteEditor = el => {
const { isSupportedContent, projectId, path: sourcePath, baseUrl } = el.dataset; const {
isSupportedContent,
projectId,
path: sourcePath,
baseUrl,
namespace,
project,
} = el.dataset;
const { current_username: username } = window.gon; const { current_username: username } = window.gon;
const returnUrl = el.dataset.returnUrl || null; const returnUrl = el.dataset.returnUrl || null;
...@@ -22,7 +29,7 @@ const initStaticSiteEditor = el => { ...@@ -22,7 +29,7 @@ const initStaticSiteEditor = el => {
const router = createRouter(baseUrl); const router = createRouter(baseUrl);
const apolloProvider = createApolloProvider({ const apolloProvider = createApolloProvider({
isSupportedContent: parseBoolean(isSupportedContent), isSupportedContent: parseBoolean(isSupportedContent),
projectId, project: `${namespace}/${project}`,
returnUrl, returnUrl,
sourcePath, sourcePath,
username, username,
......
...@@ -11,6 +11,8 @@ import PublishToolbar from '../components/publish_toolbar.vue'; ...@@ -11,6 +11,8 @@ import PublishToolbar from '../components/publish_toolbar.vue';
import InvalidContentMessage from '../components/invalid_content_message.vue'; import InvalidContentMessage from '../components/invalid_content_message.vue';
import SubmitChangesError from '../components/submit_changes_error.vue'; import SubmitChangesError from '../components/submit_changes_error.vue';
import appDataQuery from '../graphql/queries/app_data.query.graphql';
export default { export default {
components: { components: {
RichContentEditor, RichContentEditor,
...@@ -23,13 +25,17 @@ export default { ...@@ -23,13 +25,17 @@ export default {
SubmitChangesError, SubmitChangesError,
}, },
mixins: [glFeatureFlagsMixin()], mixins: [glFeatureFlagsMixin()],
apollo: {
isSupportedContent: {
query: appDataQuery,
},
},
computed: { computed: {
...mapState([ ...mapState([
'content', 'content',
'isLoadingContent', 'isLoadingContent',
'isSavingChanges', 'isSavingChanges',
'isContentLoaded', 'isContentLoaded',
'isSupportedContent',
'returnUrl', 'returnUrl',
'title', 'title',
'submitChangesError', 'submitChangesError',
......
...@@ -6,7 +6,6 @@ const createState = (initialState = {}) => ({ ...@@ -6,7 +6,6 @@ const createState = (initialState = {}) => ({
isLoadingContent: false, isLoadingContent: false,
isSavingChanges: false, isSavingChanges: false,
isSupportedContent: false,
isContentLoaded: false, isContentLoaded: false,
......
...@@ -13,14 +13,11 @@ Terminal.applyAddon(webLinks); ...@@ -13,14 +13,11 @@ Terminal.applyAddon(webLinks);
export default class GLTerminal { export default class GLTerminal {
constructor(element, options = {}) { constructor(element, options = {}) {
this.options = Object.assign( this.options = {
{},
{
cursorBlink: true, cursorBlink: true,
screenKeys: true, screenKeys: true,
}, ...options,
options, };
);
this.container = element; this.container = element;
this.onDispose = []; this.onDispose = [];
......
...@@ -164,7 +164,11 @@ export default { ...@@ -164,7 +164,11 @@ export default {
'js-dropdown-button', 'js-dropdown-button',
'js-btn-cancel-create', 'js-btn-cancel-create',
'js-sidebar-dropdown-toggle', 'js-sidebar-dropdown-toggle',
].some(className => target?.classList.contains(className)); ].some(
className =>
target?.classList.contains(className) ||
target?.parentElement.classList.contains(className),
);
const hadExceptionParent = ['.js-btn-back', '.js-labels-list'].some( const hadExceptionParent = ['.js-btn-back', '.js-labels-list'].some(
className => $(target).parents(className).length, className => $(target).parents(className).length,
......
.selected-item::before {
content: '\f00c';
color: $green-500;
position: absolute;
left: 16px;
top: 16px;
transform: translateY(-50%);
font: 14px FontAwesome;
}
.dropdown-item-space {
padding: 8px 12px;
}
...@@ -40,7 +40,7 @@ ...@@ -40,7 +40,7 @@
h1, h1,
h2, h2,
h3, h3,
h4:not(.modal-title), h4,
h5, h5,
h6, h6,
code, code,
...@@ -80,10 +80,6 @@ ...@@ -80,10 +80,6 @@
background-color: $dropdown-hover-background; background-color: $dropdown-hover-background;
} }
.modal-body {
color: $gl-text-color;
}
.dropdown-menu-toggle svg, .dropdown-menu-toggle svg,
.dropdown-menu-toggle svg:hover, .dropdown-menu-toggle svg:hover,
.ide-tree-header:not(.ide-pipeline-header) svg, .ide-tree-header:not(.ide-pipeline-header) svg,
......
...@@ -660,10 +660,6 @@ $note-form-margin-left: 72px; ...@@ -660,10 +660,6 @@ $note-form-margin-left: 72px;
padding-bottom: 0; padding-bottom: 0;
} }
.note-headline-light {
display: inline;
}
.note-headline-light, .note-headline-light,
.discussion-headline-light { .discussion-headline-light {
color: $gl-text-color-secondary; color: $gl-text-color-secondary;
......
...@@ -3,8 +3,10 @@ ...@@ -3,8 +3,10 @@
class Projects::AlertManagementController < Projects::ApplicationController class Projects::AlertManagementController < Projects::ApplicationController
before_action :ensure_list_feature_enabled, only: :index before_action :ensure_list_feature_enabled, only: :index
before_action :ensure_detail_feature_enabled, only: :details before_action :ensure_detail_feature_enabled, only: :details
before_action :authorize_read_alert_management_alert!
before_action do before_action do
push_frontend_feature_flag(:alert_list_status_filtering_enabled) push_frontend_feature_flag(:alert_list_status_filtering_enabled)
push_frontend_feature_flag(:create_issue_from_alert_enabled)
end end
def index def index
......
...@@ -52,6 +52,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -52,6 +52,7 @@ class Projects::IssuesController < Projects::ApplicationController
before_action only: :show do before_action only: :show do
push_frontend_feature_flag(:real_time_issue_sidebar, @project) push_frontend_feature_flag(:real_time_issue_sidebar, @project)
push_frontend_feature_flag(:confidential_notes, @project)
end end
around_action :allow_gitaly_ref_name_caching, only: [:discussions] around_action :allow_gitaly_ref_name_caching, only: [:discussions]
...@@ -85,11 +86,13 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -85,11 +86,13 @@ class Projects::IssuesController < Projects::ApplicationController
) )
build_params = issue_params.merge( build_params = issue_params.merge(
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
discussion_to_resolve: params[:discussion_to_resolve] discussion_to_resolve: params[:discussion_to_resolve],
confidential: !!Gitlab::Utils.to_boolean(params[:issue][:confidential])
) )
service = Issues::BuildService.new(project, current_user, build_params) service = Issues::BuildService.new(project, current_user, build_params)
@issue = @noteable = service.execute @issue = @noteable = service.execute
@merge_request_to_resolve_discussions_of = service.merge_request_to_resolve_discussions_of @merge_request_to_resolve_discussions_of = service.merge_request_to_resolve_discussions_of
@discussion_to_resolve = service.discussions_to_resolve.first if params[:discussion_to_resolve] @discussion_to_resolve = service.discussions_to_resolve.first if params[:discussion_to_resolve]
......
...@@ -7,6 +7,8 @@ class Projects::PagesDomainsController < Projects::ApplicationController ...@@ -7,6 +7,8 @@ class Projects::PagesDomainsController < Projects::ApplicationController
before_action :authorize_update_pages! before_action :authorize_update_pages!
before_action :domain, except: [:new, :create] before_action :domain, except: [:new, :create]
helper_method :domain_presenter
def show def show
end end
...@@ -27,7 +29,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController ...@@ -27,7 +29,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController
end end
def retry_auto_ssl def retry_auto_ssl
PagesDomains::RetryAcmeOrderService.new(@domain.pages_domain).execute PagesDomains::RetryAcmeOrderService.new(@domain).execute
redirect_to project_pages_domain_path(@project, @domain) redirect_to project_pages_domain_path(@project, @domain)
end end
...@@ -88,6 +90,10 @@ class Projects::PagesDomainsController < Projects::ApplicationController ...@@ -88,6 +90,10 @@ class Projects::PagesDomainsController < Projects::ApplicationController
end end
def domain def domain
@domain ||= @project.pages_domains.find_by_domain!(params[:id].to_s).present(current_user: current_user) @domain ||= @project.pages_domains.find_by_domain!(params[:id].to_s)
end
def domain_presenter
@domain_presenter ||= domain.present(current_user: current_user)
end end
end end
...@@ -31,7 +31,7 @@ module AlertManagement ...@@ -31,7 +31,7 @@ module AlertManagement
end end
def authorized? def authorized?
Ability.allowed?(current_user, :read_alert_management_alerts, project) Ability.allowed?(current_user, :read_alert_management_alert, project)
end end
end end
end end
...@@ -18,7 +18,7 @@ module Mutations ...@@ -18,7 +18,7 @@ module Mutations
null: true, null: true,
description: "The alert after mutation" description: "The alert after mutation"
authorize :update_alert_management_alerts authorize :update_alert_management_alert
private private
......
...@@ -6,7 +6,7 @@ module Types ...@@ -6,7 +6,7 @@ module Types
graphql_name 'AlertManagementAlert' graphql_name 'AlertManagementAlert'
description "Describes an alert from the project's Alert Management" description "Describes an alert from the project's Alert Management"
authorize :read_alert_management_alerts authorize :read_alert_management_alert
field :iid, field :iid,
GraphQL::ID_TYPE, GraphQL::ID_TYPE,
......
...@@ -4,17 +4,18 @@ module Projects::AlertManagementHelper ...@@ -4,17 +4,18 @@ module Projects::AlertManagementHelper
def alert_management_data(current_user, project) def alert_management_data(current_user, project)
{ {
'project-path' => project.full_path, 'project-path' => project.full_path,
'enable-alert-management-path' => project_settings_operations_path(project), 'enable-alert-management-path' => edit_project_service_path(project, AlertsService),
'empty-alert-svg-path' => image_path('illustrations/alert-management-empty-state.svg'), 'empty-alert-svg-path' => image_path('illustrations/alert-management-empty-state.svg'),
'user-can-enable-alert-management' => 'false', 'user-can-enable-alert-management' => can?(current_user, :admin_project, project).to_s,
'alert-management-enabled' => Feature.enabled?(:alert_management_minimal, project).to_s 'alert-management-enabled' => (!!project.alerts_service_activated?).to_s
} }
end end
def alert_management_detail_data(project_path, alert_id) def alert_management_detail_data(project, alert_id)
{ {
'alert-id' => alert_id, 'alert-id' => alert_id,
'project-path' => project_path 'project-path' => project.full_path,
'new-issue-path' => new_project_issue_path(project)
} }
end end
end end
...@@ -448,7 +448,7 @@ module ProjectsHelper ...@@ -448,7 +448,7 @@ module ProjectsHelper
clusters: :read_cluster, clusters: :read_cluster,
serverless: :read_cluster, serverless: :read_cluster,
error_tracking: :read_sentry_issue, error_tracking: :read_sentry_issue,
alert_management: :read_alert_management, alert_management: :read_alert_management_alert,
labels: :read_label, labels: :read_label,
issues: :read_issue, issues: :read_issue,
project_members: :read_project_member, project_members: :read_project_member,
......
...@@ -30,7 +30,9 @@ module ReleasesHelper ...@@ -30,7 +30,9 @@ module ReleasesHelper
markdown_docs_path: help_page_path('user/markdown'), markdown_docs_path: help_page_path('user/markdown'),
releases_page_path: project_releases_path(@project, anchor: @release.tag), releases_page_path: project_releases_path(@project, anchor: @release.tag),
update_release_api_docs_path: help_page_path('api/releases/index.md', anchor: 'update-a-release'), update_release_api_docs_path: help_page_path('api/releases/index.md', anchor: 'update-a-release'),
release_assets_docs_path: help_page(anchor: 'release-assets') release_assets_docs_path: help_page(anchor: 'release-assets'),
manage_milestones_path: project_milestones_path(@project),
new_milestone_path: new_project_milestone_url(@project)
} }
end end
end end
...@@ -115,8 +115,11 @@ module Ci ...@@ -115,8 +115,11 @@ module Ci
state_machine :status, initial: :created do state_machine :status, initial: :created do
event :enqueue do event :enqueue do
transition [:created, :waiting_for_resource, :preparing, :skipped, :scheduled] => :pending transition [:created, :manual, :waiting_for_resource, :preparing, :skipped, :scheduled] => :pending
transition [:success, :failed, :canceled] => :running transition [:success, :failed, :canceled] => :running
# this is needed to ensure tests to be covered
transition [:running] => :running
end end
event :request_resource do event :request_resource do
...@@ -683,6 +686,8 @@ module Ci ...@@ -683,6 +686,8 @@ module Ci
variables.concat(merge_request.predefined_variables) variables.concat(merge_request.predefined_variables)
end end
variables.append(key: 'CI_KUBERNETES_ACTIVE', value: 'true') if has_kubernetes_active?
if external_pull_request_event? && external_pull_request if external_pull_request_event? && external_pull_request
variables.concat(external_pull_request.predefined_variables) variables.concat(external_pull_request.predefined_variables)
end end
......
...@@ -42,8 +42,7 @@ module Ci ...@@ -42,8 +42,7 @@ module Ci
state_machine :status, initial: :created do state_machine :status, initial: :created do
event :enqueue do event :enqueue do
transition [:created, :waiting_for_resource, :preparing] => :pending transition any - [:pending] => :pending
transition [:success, :failed, :canceled, :skipped] => :running
end end
event :request_resource do event :request_resource do
......
...@@ -5,7 +5,7 @@ module Clusters ...@@ -5,7 +5,7 @@ module Clusters
class Knative < ApplicationRecord class Knative < ApplicationRecord
VERSION = '0.9.0' VERSION = '0.9.0'
REPOSITORY = 'https://charts.gitlab.io' REPOSITORY = 'https://charts.gitlab.io'
METRICS_CONFIG = 'https://gitlab.com/gitlab-org/charts/knative/-/blob/v0.9.0/vendor/istio-metrics.yml' METRICS_CONFIG = 'https://gitlab.com/gitlab-org/charts/knative/-/raw/v0.9.0/vendor/istio-metrics.yml'
FETCH_IP_ADDRESS_DELAY = 30.seconds FETCH_IP_ADDRESS_DELAY = 30.seconds
API_GROUPS_PATH = 'config/knative/api_groups.yml' API_GROUPS_PATH = 'config/knative/api_groups.yml'
......
...@@ -1436,20 +1436,12 @@ class Project < ApplicationRecord ...@@ -1436,20 +1436,12 @@ class Project < ApplicationRecord
# Expires various caches before a project is renamed. # Expires various caches before a project is renamed.
def expire_caches_before_rename(old_path) def expire_caches_before_rename(old_path)
repo = Repository.new(old_path, self, shard: repository_storage) project_repo = Repository.new(old_path, self, shard: repository_storage)
wiki = Repository.new("#{old_path}.wiki", self, shard: repository_storage, repo_type: Gitlab::GlRepository::WIKI) wiki_repo = Repository.new("#{old_path}#{Gitlab::GlRepository::WIKI.path_suffix}", self, shard: repository_storage, repo_type: Gitlab::GlRepository::WIKI)
design = Repository.new("#{old_path}#{Gitlab::GlRepository::DESIGN.path_suffix}", self, shard: repository_storage, repo_type: Gitlab::GlRepository::DESIGN) design_repo = Repository.new("#{old_path}#{Gitlab::GlRepository::DESIGN.path_suffix}", self, shard: repository_storage, repo_type: Gitlab::GlRepository::DESIGN)
if repo.exists? [project_repo, wiki_repo, design_repo].each do |repo|
repo.before_delete repo.before_delete if repo.exists?
end
if wiki.exists?
wiki.before_delete
end
if design.exists?
design.before_delete
end end
end end
......
...@@ -236,11 +236,8 @@ class ProjectPolicy < BasePolicy ...@@ -236,11 +236,8 @@ class ProjectPolicy < BasePolicy
enable :read_merge_request enable :read_merge_request
enable :read_sentry_issue enable :read_sentry_issue
enable :update_sentry_issue enable :update_sentry_issue
enable :read_alert_management
enable :read_prometheus enable :read_prometheus
enable :read_metrics_dashboard_annotation enable :read_metrics_dashboard_annotation
enable :read_alert_management_alerts
enable :update_alert_management_alerts
enable :metrics_dashboard enable :metrics_dashboard
end end
...@@ -306,6 +303,8 @@ class ProjectPolicy < BasePolicy ...@@ -306,6 +303,8 @@ class ProjectPolicy < BasePolicy
enable :create_metrics_dashboard_annotation enable :create_metrics_dashboard_annotation
enable :delete_metrics_dashboard_annotation enable :delete_metrics_dashboard_annotation
enable :update_metrics_dashboard_annotation enable :update_metrics_dashboard_annotation
enable :read_alert_management_alert
enable :update_alert_management_alert
enable :create_design enable :create_design
enable :destroy_design enable :destroy_design
end end
......
...@@ -7,6 +7,7 @@ module Projects ...@@ -7,6 +7,7 @@ module Projects
GENERIC_ALERT_SUMMARY_ANNOTATIONS = %w(monitoring_tool service hosts).freeze GENERIC_ALERT_SUMMARY_ANNOTATIONS = %w(monitoring_tool service hosts).freeze
MARKDOWN_LINE_BREAK = " \n".freeze MARKDOWN_LINE_BREAK = " \n".freeze
INCIDENT_LABEL_NAME = IncidentManagement::CreateIssueService::INCIDENT_LABEL[:title].freeze INCIDENT_LABEL_NAME = IncidentManagement::CreateIssueService::INCIDENT_LABEL[:title].freeze
METRIC_TIME_WINDOW = 30.minutes
def full_title def full_title
[environment_name, alert_title].compact.join(': ') [environment_name, alert_title].compact.join(': ')
...@@ -119,9 +120,63 @@ module Projects ...@@ -119,9 +120,63 @@ module Projects
Array(hosts.value).join(' ') Array(hosts.value).join(' ')
end end
def metric_embed_for_alert; end def metric_embed_for_alert
url = embed_url_for_gitlab_alert || embed_url_for_self_managed_alert
"\n[](#{url})" if url
end end
def embed_url_for_gitlab_alert
return unless gitlab_alert
metrics_dashboard_project_prometheus_alert_url(
project,
gitlab_alert.prometheus_metric_id,
environment_id: environment.id,
**alert_embed_window_params(embed_time)
)
end
def embed_url_for_self_managed_alert
return unless environment && full_query && title
metrics_dashboard_project_environment_url(
project,
environment,
embed_json: dashboard_for_self_managed_alert.to_json,
**alert_embed_window_params(embed_time)
)
end
def embed_time
starts_at ? Time.rfc3339(starts_at) : Time.current
end
def alert_embed_window_params(time)
{
start: format_embed_timestamp(time - METRIC_TIME_WINDOW),
end: format_embed_timestamp(time + METRIC_TIME_WINDOW)
}
end
def format_embed_timestamp(time)
time.utc.strftime('%FT%TZ')
end end
end
Projects::Prometheus::AlertPresenter.prepend_if_ee('EE::Projects::Prometheus::AlertPresenter') def dashboard_for_self_managed_alert
{
panel_groups: [{
panels: [{
type: 'line-graph',
title: title,
y_label: y_label,
metrics: [{
query_range: full_query
}]
}]
}]
}
end
end
end
end
# frozen_string_literal: true
class AccessibilityErrorEntity < Grape::Entity
expose :code
expose :type
expose :typeCode, as: :type_code
expose :message
expose :context
expose :selector
expose :runner
expose :runnerExtras, as: :runner_extras
end
# frozen_string_literal: true
class AccessibilityReportsComparerEntity < Grape::Entity
expose :status
expose :new_errors, using: AccessibilityErrorEntity
expose :resolved_errors, using: AccessibilityErrorEntity
expose :existing_errors, using: AccessibilityErrorEntity
expose :summary do
expose :total_count, as: :total
expose :resolved_count, as: :resolved
expose :errors_count, as: :errored
end
end
# frozen_string_literal: true
class AccessibilityReportsComparerSerializer < BaseSerializer
entity AccessibilityReportsComparerEntity
end
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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