diff options
Diffstat (limited to 'app/assets')
91 files changed, 1886 insertions, 772 deletions
diff --git a/app/assets/javascripts/admin/application_settings/setup_metrics_and_profiling.js b/app/assets/javascripts/admin/application_settings/setup_metrics_and_profiling.js new file mode 100644 index 00000000000..b4803be4d52 --- /dev/null +++ b/app/assets/javascripts/admin/application_settings/setup_metrics_and_profiling.js @@ -0,0 +1,8 @@ +import PayloadPreviewer from '~/pages/admin/application_settings/payload_previewer'; + +export default () => { + new PayloadPreviewer( + document.querySelector('.js-usage-ping-payload-trigger'), + document.querySelector('.js-usage-ping-payload'), + ).init(); +}; diff --git a/app/assets/javascripts/blob/components/blob_edit_content.vue b/app/assets/javascripts/blob/components/blob_edit_content.vue index 9a30ed93330..056b4ea4aa8 100644 --- a/app/assets/javascripts/blob/components/blob_edit_content.vue +++ b/app/assets/javascripts/blob/components/blob_edit_content.vue @@ -1,5 +1,6 @@ <script> import { initEditorLite } from '~/blob/utils'; +import { debounce } from 'lodash'; export default { props: { @@ -32,16 +33,14 @@ export default { }); }, methods: { - triggerFileChange() { + triggerFileChange: debounce(function debouncedFileChange() { this.$emit('input', this.editor.getValue()); - }, + }, 250), }, }; </script> <template> <div class="file-content code"> - <pre id="editor" ref="editor" data-editor-loading @focusout="triggerFileChange">{{ - value - }}</pre> + <pre id="editor" ref="editor" data-editor-loading @keyup="triggerFileChange">{{ value }}</pre> </div> </template> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue b/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue new file mode 100644 index 00000000000..f5c2cc57f3f --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue @@ -0,0 +1,169 @@ +<script> +import { uniqueId } from 'lodash'; +import { GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui'; + +export default { + name: 'CiKeyField', + components: { + GlButton, + GlFormGroup, + GlFormInput, + }, + model: { + prop: 'value', + event: 'input', + }, + props: { + tokenList: { + type: Array, + required: true, + }, + value: { + type: String, + required: true, + }, + }, + data() { + return { + results: [], + arrowCounter: -1, + userDismissedResults: false, + suggestionsId: uniqueId('token-suggestions-'), + }; + }, + computed: { + showAutocomplete() { + return this.showSuggestions ? 'off' : 'on'; + }, + showSuggestions() { + return this.results.length > 0; + }, + }, + mounted() { + document.addEventListener('click', this.handleClickOutside); + }, + destroyed() { + document.removeEventListener('click', this.handleClickOutside); + }, + methods: { + closeSuggestions() { + this.results = []; + this.arrowCounter = -1; + }, + handleClickOutside(event) { + if (!this.$el.contains(event.target)) { + this.closeSuggestions(); + } + }, + onArrowDown() { + const newCount = this.arrowCounter + 1; + + if (newCount >= this.results.length) { + this.arrowCounter = 0; + return; + } + + this.arrowCounter = newCount; + }, + onArrowUp() { + const newCount = this.arrowCounter - 1; + + if (newCount < 0) { + this.arrowCounter = this.results.length - 1; + return; + } + + this.arrowCounter = newCount; + }, + onEnter() { + const currentToken = this.results[this.arrowCounter] || this.value; + this.selectToken(currentToken); + }, + onEsc() { + if (!this.showSuggestions) { + this.$emit('input', ''); + } + this.closeSuggestions(); + this.userDismissedResults = true; + }, + onEntry(value) { + this.$emit('input', value); + this.userDismissedResults = false; + + // short circuit so that we don't false match on empty string + if (value.length < 1) { + this.closeSuggestions(); + return; + } + + const filteredTokens = this.tokenList.filter(token => + token.toLowerCase().includes(value.toLowerCase()), + ); + + if (filteredTokens.length) { + this.openSuggestions(filteredTokens); + } else { + this.closeSuggestions(); + } + }, + openSuggestions(filteredResults) { + this.results = filteredResults; + }, + selectToken(value) { + this.$emit('input', value); + this.closeSuggestions(); + this.$emit('key-selected'); + }, + }, +}; +</script> +<template> + <div> + <div class="dropdown position-relative" role="combobox" aria-owns="token-suggestions"> + <gl-form-group :label="__('Key')" label-for="ci-variable-key"> + <gl-form-input + id="ci-variable-key" + :value="value" + type="text" + role="searchbox" + class="form-control pl-2 js-env-input" + :autocomplete="showAutocomplete" + aria-autocomplete="list" + aria-controls="token-suggestions" + aria-haspopup="listbox" + :aria-expanded="showSuggestions" + data-qa-selector="ci_variable_key_field" + @input="onEntry" + @keydown.down="onArrowDown" + @keydown.up="onArrowUp" + @keydown.enter.prevent="onEnter" + @keydown.esc.stop="onEsc" + @keydown.tab="closeSuggestions" + /> + </gl-form-group> + + <div + v-show="showSuggestions && !userDismissedResults" + id="ci-variable-dropdown" + class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width" + :class="{ 'd-block': showSuggestions }" + > + <div class="dropdown-content"> + <ul :id="suggestionsId"> + <li + v-for="(result, i) in results" + :key="i" + role="option" + :class="{ 'gl-bg-gray-100': i === arrowCounter }" + :aria-selected="i === arrowCounter" + > + <gl-button tabindex="-1" class="btn-transparent pl-2" @click="selectToken(result)">{{ + result + }}</gl-button> + </li> + </ul> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js b/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js new file mode 100644 index 00000000000..9022bf51514 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js @@ -0,0 +1,29 @@ +import { __ } from '~/locale'; + +import { AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_SECRET_ACCESS_KEY } from '../constants'; + +export const awsTokens = { + [AWS_ACCESS_KEY_ID]: { + name: AWS_ACCESS_KEY_ID, + /* Checks for exactly twenty characters that match key. + Based on greps suggested by Amazon at: + https://aws.amazon.com/blogs/security/a-safer-way-to-distribute-aws-credentials-to-ec2/ + */ + validation: val => /^[A-Za-z0-9]{20}$/.test(val), + invalidMessage: __('This variable does not match the expected pattern.'), + }, + [AWS_DEFAULT_REGION]: { + name: AWS_DEFAULT_REGION, + }, + [AWS_SECRET_ACCESS_KEY]: { + name: AWS_SECRET_ACCESS_KEY, + /* Checks for exactly forty characters that match secret. + Based on greps suggested by Amazon at: + https://aws.amazon.com/blogs/security/a-safer-way-to-distribute-aws-credentials-to-ec2/ + */ + validation: val => /^[A-Za-z0-9/+=]{40}$/.test(val), + invalidMessage: __('This variable does not match the expected pattern.'), + }, +}; + +export const awsTokenList = Object.keys(awsTokens); diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue index 316408adfb2..8f5acd4a0a0 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue @@ -1,8 +1,4 @@ <script> -import { __ } from '~/locale'; -import { mapActions, mapState } from 'vuex'; -import { ADD_CI_VARIABLE_MODAL_ID } from '../constants'; -import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; import { GlDeprecatedButton, GlModal, @@ -14,11 +10,19 @@ import { GlLink, GlIcon, } from '@gitlab/ui'; +import { mapActions, mapState } from 'vuex'; +import { __ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { ADD_CI_VARIABLE_MODAL_ID } from '../constants'; +import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens'; +import CiKeyField from './ci_key_field.vue'; +import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; export default { modalId: ADD_CI_VARIABLE_MODAL_ID, components: { CiEnvironmentsDropdown, + CiKeyField, GlDeprecatedButton, GlModal, GlFormSelect, @@ -29,6 +33,9 @@ export default { GlLink, GlIcon, }, + mixins: [glFeatureFlagsMixin()], + tokens: awsTokens, + tokenList: awsTokenList, computed: { ...mapState([ 'projectId', @@ -41,23 +48,24 @@ export default { 'selectedEnvironment', ]), canSubmit() { - if (this.variableData.masked && this.maskedState === false) { - return false; - } - return this.variableData.key !== '' && this.variableData.secret_value !== ''; + return ( + this.variableValidationState && + this.variableData.key !== '' && + this.variableData.secret_value !== '' + ); }, canMask() { const regex = RegExp(this.maskableRegex); return regex.test(this.variableData.secret_value); }, displayMaskedError() { - return !this.canMask && this.variableData.masked && this.variableData.secret_value !== ''; + return !this.canMask && this.variableData.masked; }, maskedState() { if (this.displayMaskedError) { return false; } - return null; + return true; }, variableData() { return this.variableBeingEdited || this.variable; @@ -66,7 +74,41 @@ export default { return this.variableBeingEdited ? __('Update variable') : __('Add variable'); }, maskedFeedback() { - return __('This variable can not be masked'); + return this.displayMaskedError ? __('This variable can not be masked.') : ''; + }, + tokenValidationFeedback() { + const tokenSpecificFeedback = this.$options.tokens?.[this.variableData.key]?.invalidMessage; + if (!this.tokenValidationState && tokenSpecificFeedback) { + return tokenSpecificFeedback; + } + return ''; + }, + tokenValidationState() { + // If the feature flag is off, do not validate. Remove when flag is removed. + if (!this.glFeatures.ciKeyAutocomplete) { + return true; + } + + const validator = this.$options.tokens?.[this.variableData.key]?.validation; + + if (validator) { + return validator(this.variableData.secret_value); + } + + return true; + }, + variableValidationFeedback() { + return `${this.tokenValidationFeedback} ${this.maskedFeedback}`; + }, + variableValidationState() { + if ( + this.variableData.secret_value === '' || + (this.tokenValidationState && this.maskedState) + ) { + return true; + } + + return false; }, }, methods: { @@ -82,14 +124,13 @@ export default { 'resetSelectedEnvironment', 'setSelectedEnvironment', ]), - updateOrAddVariable() { - if (this.variableBeingEdited) { - this.updateVariable(this.variableBeingEdited); - } else { - this.addVariable(); - } + deleteVarAndClose() { + this.deleteVariable(this.variableBeingEdited); this.hideModal(); }, + hideModal() { + this.$refs.modal.hide(); + }, resetModalHandler() { if (this.variableBeingEdited) { this.resetEditing(); @@ -98,11 +139,12 @@ export default { } this.resetSelectedEnvironment(); }, - hideModal() { - this.$refs.modal.hide(); - }, - deleteVarAndClose() { - this.deleteVariable(this.variableBeingEdited); + updateOrAddVariable() { + if (this.variableBeingEdited) { + this.updateVariable(this.variableBeingEdited); + } else { + this.addVariable(); + } this.hideModal(); }, }, @@ -119,7 +161,13 @@ export default { @hidden="resetModalHandler" > <form> - <gl-form-group :label="__('Key')" label-for="ci-variable-key"> + <ci-key-field + v-if="glFeatures.ciKeyAutocomplete" + v-model="variableData.key" + :token-list="$options.tokenList" + /> + + <gl-form-group v-else :label="__('Key')" label-for="ci-variable-key"> <gl-form-input id="ci-variable-key" v-model="variableData.key" @@ -130,12 +178,14 @@ export default { <gl-form-group :label="__('Value')" label-for="ci-variable-value" - :state="maskedState" - :invalid-feedback="maskedFeedback" + :state="variableValidationState" + :invalid-feedback="variableValidationFeedback" > <gl-form-textarea id="ci-variable-value" + ref="valueField" v-model="variableData.secret_value" + :state="variableValidationState" rows="3" max-rows="6" data-qa-selector="ci_variable_value_field" diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js index d22138db102..5fe1e32e37e 100644 --- a/app/assets/javascripts/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci_variable_list/constants.js @@ -14,3 +14,8 @@ export const types = { fileType: 'file', allEnvironmentsType: '*', }; + +// AWS TOKEN CONSTANTS +export const AWS_ACCESS_KEY_ID = 'AWS_ACCESS_KEY_ID'; +export const AWS_DEFAULT_REGION = 'AWS_DEFAULT_REGION'; +export const AWS_SECRET_ACCESS_KEY = 'AWS_SECRET_ACCESS_KEY'; diff --git a/app/assets/javascripts/clusters/services/application_state_machine.js b/app/assets/javascripts/clusters/services/application_state_machine.js index 6bc4be7b93a..6af9b10f12f 100644 --- a/app/assets/javascripts/clusters/services/application_state_machine.js +++ b/app/assets/javascripts/clusters/services/application_state_machine.js @@ -191,7 +191,8 @@ const applicationStateMachine = { * @param {*} event */ const transitionApplicationState = (application, event) => { - const newState = applicationStateMachine[application.status].on[event]; + const stateMachine = applicationStateMachine[application.status]; + const newState = stateMachine !== undefined ? stateMachine.on[event] : false; return newState ? { diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js index 51879f280e0..41988f321e5 100644 --- a/app/assets/javascripts/contextual_sidebar.js +++ b/app/assets/javascripts/contextual_sidebar.js @@ -1,6 +1,6 @@ import $ from 'jquery'; +import { debounce } from 'lodash'; import Cookies from 'js-cookie'; -import _ from 'underscore'; import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils'; import { parseBoolean } from '~/lib/utils/common_utils'; @@ -43,7 +43,7 @@ export default class ContextualSidebar { $(document).trigger('content.resize'); }); - $(window).on('resize', () => _.debounce(this.render(), 100)); + $(window).on('resize', debounce(() => this.render(), 100)); } // See documentation: https://design.gitlab.com/regions/navigation#contextual-navigation diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js index 229612f5e9d..ba585444ba5 100644 --- a/app/assets/javascripts/create_merge_request_dropdown.js +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -1,5 +1,5 @@ /* eslint-disable no-new */ -import _ from 'underscore'; +import { debounce } from 'lodash'; import axios from './lib/utils/axios_utils'; import Flash from './flash'; import DropLab from './droplab/drop_lab'; @@ -55,7 +55,7 @@ export default class CreateMergeRequestDropdown { this.isCreatingMergeRequest = false; this.isGettingRef = false; this.mergeRequestCreated = false; - this.refDebounce = _.debounce((value, target) => this.getRef(value, target), 500); + this.refDebounce = debounce((value, target) => this.getRef(value, target), 500); this.refIsValid = true; this.refsPath = this.wrapperEl.dataset.refsPath; this.suggestedRef = this.refInput.value; diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index 6d2b11e39d3..f609ca5f22d 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -59,16 +59,10 @@ export default () => { service: this.createCycleAnalyticsService(cycleAnalyticsEl.dataset.requestPath), }; }, - defaultNumberOfSummaryItems: 3, computed: { currentStage() { return this.store.currentActiveStage(); }, - summaryTableColumnClass() { - return this.state.summary.length === this.$options.defaultNumberOfSummaryItems - ? 'col-sm-3' - : 'col-sm-4'; - }, }, created() { // Conditional check placed here to prevent this method from being called on the diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue index 9544fbe9fc5..514d26862a3 100644 --- a/app/assets/javascripts/diffs/components/diff_table_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue @@ -99,8 +99,12 @@ export default { return this.showCommentButton && this.hasDiscussions; }, shouldRenderCommentButton() { - const isDiffHead = parseBoolean(getParameterByName('diff_head')); - return !isDiffHead && this.isLoggedIn && this.showCommentButton; + if (this.isLoggedIn && this.showCommentButton) { + const isDiffHead = parseBoolean(getParameterByName('diff_head')); + return !isDiffHead || gon.features?.mergeRefHeadComments; + } + + return false; }, isMatchLine() { return this.line.type === MATCH_LINE_TYPE; diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index b07dfe5f33d..40e1aec42ed 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -60,3 +60,4 @@ export const PARALLEL_DIFF_LINES_KEY = 'parallel_diff_lines'; export const DIFFS_PER_PAGE = 20; export const DIFF_COMPARE_BASE_VERSION_INDEX = -1; +export const DIFF_COMPARE_HEAD_VERSION_INDEX = -2; diff --git a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js index 14c51602f28..dd682060b4b 100644 --- a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js +++ b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js @@ -1,5 +1,6 @@ import { __, n__, sprintf } from '~/locale'; -import { DIFF_COMPARE_BASE_VERSION_INDEX } from '../constants'; +import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils'; +import { DIFF_COMPARE_BASE_VERSION_INDEX, DIFF_COMPARE_HEAD_VERSION_INDEX } from '../constants'; export const selectedTargetIndex = state => state.startVersion?.version_index || DIFF_COMPARE_BASE_VERSION_INDEX; @@ -9,12 +10,25 @@ export const selectedSourceIndex = state => state.mergeRequestDiff.version_index export const diffCompareDropdownTargetVersions = (state, getters) => { // startVersion only exists if the user has selected a version other // than "base" so if startVersion is null then base must be selected + + const diffHead = parseBoolean(getParameterByName('diff_head')); + const isBaseSelected = !state.startVersion && !diffHead; + const isHeadSelected = !state.startVersion && diffHead; + const baseVersion = { versionName: state.targetBranchName, version_index: DIFF_COMPARE_BASE_VERSION_INDEX, href: state.mergeRequestDiff.base_version_path, isBase: true, - selected: !state.startVersion, + selected: isBaseSelected, + }; + + const headVersion = { + versionName: state.targetBranchName, + version_index: DIFF_COMPARE_HEAD_VERSION_INDEX, + href: state.mergeRequestDiff.head_version_path, + isHead: true, + selected: isHeadSelected, }; // Appended properties here are to make the compare_dropdown_layout easier to reason about const formatVersion = v => { @@ -25,7 +39,7 @@ export const diffCompareDropdownTargetVersions = (state, getters) => { ...v, }; }; - return [...state.mergeRequestDiffs.slice(1).map(formatVersion), baseVersion]; + return [...state.mergeRequestDiffs.slice(1).map(formatVersion), baseVersion, headVersion]; }; export const diffCompareDropdownSourceVersions = (state, getters) => { diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index cc9bfa2e174..104686993a8 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -182,15 +182,18 @@ export default { [types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode, hash }) { const { latestDiff } = state; - const discussionLineCode = discussion.line_code; + const discussionLineCodes = [discussion.line_code, ...(discussion.line_codes || [])]; const fileHash = discussion.diff_file.file_hash; const lineCheck = line => - line.line_code === discussionLineCode && - isDiscussionApplicableToLine({ - discussion, - diffPosition: diffPositionByLineCode[line.line_code], - latestDiff, - }); + discussionLineCodes.some( + discussionLineCode => + line.line_code === discussionLineCode && + isDiscussionApplicableToLine({ + discussion, + diffPosition: diffPositionByLineCode[line.line_code], + latestDiff, + }), + ); const mapDiscussions = (line, extraCheck = () => true) => ({ ...line, discussions: extraCheck() diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 9c788e283b9..dd8dec49a37 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -424,6 +424,7 @@ export function getDiffPositionByLineCode(diffFiles, useSingleDiffStyle) { old_path: file.old_path, old_line: line.old_line, new_line: line.new_line, + line_range: null, line_code: line.line_code, position_type: 'text', }; @@ -439,10 +440,13 @@ export function isDiscussionApplicableToLine({ discussion, diffPosition, latestD const { line_code, ...diffPositionCopy } = diffPosition; if (discussion.original_position && discussion.position) { - const originalRefs = discussion.original_position; - const refs = discussion.position; + const discussionPositions = [ + discussion.original_position, + discussion.position, + ...(discussion.positions || []), + ]; - return isEqual(refs, diffPositionCopy) || isEqual(originalRefs, diffPositionCopy); + return discussionPositions.some(position => isEqual(position, diffPositionCopy)); } // eslint-disable-next-line diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js index be2eee828ff..4aad54bed55 100644 --- a/app/assets/javascripts/filterable_list.js +++ b/app/assets/javascripts/filterable_list.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import _ from 'underscore'; +import { debounce } from 'lodash'; import axios from './lib/utils/axios_utils'; /** @@ -29,7 +29,7 @@ export default class FilterableList { initSearch() { // Wrap to prevent passing event arguments to .filterResults; - this.debounceFilter = _.debounce(this.onFilterInput.bind(this), 500); + this.debounceFilter = debounce(this.onFilterInput.bind(this), 500); this.unbindEvents(); this.bindEvents(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 7ea7313f648..724f80f8866 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -14,7 +14,13 @@ import FilteredSearchTokenizer from './filtered_search_tokenizer'; import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; import DropdownUtils from './dropdown_utils'; -import { BACKSPACE_KEY_CODE } from '~/lib/utils/keycodes'; +import { + ENTER_KEY_CODE, + BACKSPACE_KEY_CODE, + DELETE_KEY_CODE, + UP_KEY_CODE, + DOWN_KEY_CODE, +} from '~/lib/utils/keycodes'; import { __ } from '~/locale'; export default class FilteredSearchManager { @@ -176,6 +182,8 @@ export default class FilteredSearchManager { this.checkForEnterWrapper = this.checkForEnter.bind(this); this.onClearSearchWrapper = this.onClearSearch.bind(this); this.checkForBackspaceWrapper = this.checkForBackspace.call(this); + this.checkForMetaBackspaceWrapper = this.checkForMetaBackspace.bind(this); + this.checkForAltOrCtrlBackspaceWrapper = this.checkForAltOrCtrlBackspace.bind(this); this.removeSelectedTokenKeydownWrapper = this.removeSelectedTokenKeydown.bind(this); this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this); this.editTokenWrapper = this.editToken.bind(this); @@ -192,6 +200,9 @@ export default class FilteredSearchManager { this.filteredSearchInput.addEventListener('keyup', this.handleInputVisualTokenWrapper); this.filteredSearchInput.addEventListener('keydown', this.checkForEnterWrapper); this.filteredSearchInput.addEventListener('keyup', this.checkForBackspaceWrapper); + // e.metaKey only works with keydown, not keyup + this.filteredSearchInput.addEventListener('keydown', this.checkForMetaBackspaceWrapper); + this.filteredSearchInput.addEventListener('keydown', this.checkForAltOrCtrlBackspaceWrapper); this.filteredSearchInput.addEventListener('click', this.tokenChange); this.filteredSearchInput.addEventListener('keyup', this.tokenChange); this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper); @@ -213,6 +224,8 @@ export default class FilteredSearchManager { this.filteredSearchInput.removeEventListener('input', this.handleInputPlaceholderWrapper); this.filteredSearchInput.removeEventListener('keyup', this.handleInputVisualTokenWrapper); this.filteredSearchInput.removeEventListener('keydown', this.checkForEnterWrapper); + this.filteredSearchInput.removeEventListener('keydown', this.checkForMetaBackspaceWrapper); + this.filteredSearchInput.removeEventListener('keydown', this.checkForAltOrCtrlBackspaceWrapper); this.filteredSearchInput.removeEventListener('keyup', this.checkForBackspaceWrapper); this.filteredSearchInput.removeEventListener('click', this.tokenChange); this.filteredSearchInput.removeEventListener('keyup', this.tokenChange); @@ -235,7 +248,11 @@ export default class FilteredSearchManager { return e => { // 8 = Backspace Key // 46 = Delete Key - if (e.keyCode === 8 || e.keyCode === 46) { + // Handled by respective backspace-combination check functions + if (e.altKey || e.ctrlKey || e.metaKey) { + return; + } + if (e.keyCode === BACKSPACE_KEY_CODE || e.keyCode === DELETE_KEY_CODE) { const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); const { tokenName, tokenValue } = DropdownUtils.getVisualTokenValues(lastVisualToken); const canEdit = tokenName && this.canEdit && this.canEdit(tokenName, tokenValue); @@ -258,15 +275,31 @@ export default class FilteredSearchManager { }; } + checkForAltOrCtrlBackspace(e) { + if ((e.altKey || e.ctrlKey) && e.keyCode === BACKSPACE_KEY_CODE) { + // Default to native OS behavior if input value present + if (this.filteredSearchInput.value === '') { + FilteredSearchVisualTokens.removeLastTokenPartial(); + } + } + } + + checkForMetaBackspace(e) { + const onlyMeta = e.metaKey && !e.altKey && !e.ctrlKey && !e.shiftKey; + if (onlyMeta && e.keyCode === BACKSPACE_KEY_CODE) { + this.clearSearch(); + } + } + checkForEnter(e) { - if (e.keyCode === 38 || e.keyCode === 40) { + if (e.keyCode === UP_KEY_CODE || e.keyCode === DOWN_KEY_CODE) { const { selectionStart } = this.filteredSearchInput; e.preventDefault(); this.filteredSearchInput.setSelectionRange(selectionStart, selectionStart); } - if (e.keyCode === 13) { + if (e.keyCode === ENTER_KEY_CODE) { const dropdown = this.dropdownManager.mapping[this.dropdownManager.currentDropdown]; const dropdownEl = dropdown.element; const activeElements = dropdownEl.querySelectorAll('.droplab-item-active'); @@ -375,7 +408,7 @@ export default class FilteredSearchManager { removeSelectedTokenKeydown(e) { // 8 = Backspace Key // 46 = Delete Key - if (e.keyCode === 8 || e.keyCode === 46) { + if (e.keyCode === BACKSPACE_KEY_CODE || e.keyCode === DELETE_KEY_CODE) { this.removeSelectedToken(); } } diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue index f0f5b8395c9..c7acc21378b 100644 --- a/app/assets/javascripts/groups/components/groups.vue +++ b/app/assets/javascripts/groups/components/groups.vue @@ -32,7 +32,7 @@ export default { }, methods: { change(page) { - const filterGroupsParam = getParameterByName('filter_groups'); + const filterGroupsParam = getParameterByName('filter'); const sortParam = getParameterByName('sort'); const archivedParam = getParameterByName('archived'); eventHub.$emit(`${this.action}fetchPage`, page, filterGroupsParam, sortParam, archivedParam); diff --git a/app/assets/javascripts/jira_import/components/jira_import_app.vue b/app/assets/javascripts/jira_import/components/jira_import_app.vue index 437239ce0be..b71c06e4217 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_app.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_app.vue @@ -1,12 +1,20 @@ <script> -import getJiraProjects from '../queries/getJiraProjects.query.graphql'; +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; +import getJiraImportDetailsQuery from '../queries/get_jira_import_details.query.graphql'; +import initiateJiraImportMutation from '../queries/initiate_jira_import.mutation.graphql'; +import { IMPORT_STATE, isInProgress } from '../utils'; import JiraImportForm from './jira_import_form.vue'; +import JiraImportProgress from './jira_import_progress.vue'; import JiraImportSetup from './jira_import_setup.vue'; export default { name: 'JiraImportApp', components: { + GlAlert, + GlLoadingIcon, JiraImportForm, + JiraImportProgress, JiraImportSetup, }, props: { @@ -14,6 +22,18 @@ export default { type: Boolean, required: true, }, + inProgressIllustration: { + type: String, + required: true, + }, + issuesPath: { + type: String, + required: true, + }, + jiraProjects: { + type: Array, + required: true, + }, projectPath: { type: String, required: true, @@ -23,26 +43,111 @@ export default { required: true, }, }, + data() { + return { + errorMessage: '', + showAlert: false, + }; + }, apollo: { - getJiraImports: { - query: getJiraProjects, + jiraImportDetails: { + query: getJiraImportDetailsQuery, variables() { return { fullPath: this.projectPath, }; }, - update: data => data.project.jiraImports, + update: ({ project }) => ({ + status: project.jiraImportStatus, + import: project.jiraImports.nodes[0], + }), skip() { return !this.isJiraConfigured; }, }, }, + computed: { + isImportInProgress() { + return isInProgress(this.jiraImportDetails?.status); + }, + jiraProjectsOptions() { + return this.jiraProjects.map(([text, value]) => ({ text, value })); + }, + }, + methods: { + dismissAlert() { + this.showAlert = false; + }, + initiateJiraImport(project) { + this.$apollo + .mutate({ + mutation: initiateJiraImportMutation, + variables: { + input: { + projectPath: this.projectPath, + jiraProjectKey: project, + }, + }, + update: (store, { data }) => { + if (data.jiraImportStart.errors.length) { + return; + } + + store.writeQuery({ + query: getJiraImportDetailsQuery, + variables: { + fullPath: this.projectPath, + }, + data: { + project: { + jiraImportStatus: IMPORT_STATE.SCHEDULED, + jiraImports: { + nodes: [data.jiraImportStart.jiraImport], + __typename: 'JiraImportConnection', + }, + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Project', + }, + }, + }); + }, + }) + .then(({ data }) => { + if (data.jiraImportStart.errors.length) { + this.setAlertMessage(data.jiraImportStart.errors.join('. ')); + } + }) + .catch(() => this.setAlertMessage(__('There was an error importing the Jira project.'))); + }, + setAlertMessage(message) { + this.errorMessage = message; + this.showAlert = true; + }, + }, }; </script> <template> <div> + <gl-alert v-if="showAlert" variant="danger" @dismiss="dismissAlert"> + {{ errorMessage }} + </gl-alert> + <jira-import-setup v-if="!isJiraConfigured" :illustration="setupIllustration" /> - <jira-import-form v-else /> + <gl-loading-icon v-else-if="$apollo.loading" size="md" class="mt-3" /> + <jira-import-progress + v-else-if="isImportInProgress" + :illustration="inProgressIllustration" + :import-initiator="jiraImportDetails.import.scheduledBy.name" + :import-project="jiraImportDetails.import.jiraProjectKey" + :import-time="jiraImportDetails.import.scheduledAt" + :issues-path="issuesPath" + /> + <jira-import-form + v-else + :issues-path="issuesPath" + :jira-projects="jiraProjectsOptions" + @initiateJiraImport="initiateJiraImport" + /> </div> </template> diff --git a/app/assets/javascripts/jira_import/components/jira_import_form.vue b/app/assets/javascripts/jira_import/components/jira_import_form.vue index 4de04efe1b0..0146f564260 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_form.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue @@ -1,17 +1,50 @@ <script> -import { GlAvatar, GlNewButton, GlFormGroup, GlFormSelect, GlLabel } from '@gitlab/ui'; +import { GlAvatar, GlButton, GlFormGroup, GlFormSelect, GlLabel } from '@gitlab/ui'; export default { name: 'JiraImportForm', components: { GlAvatar, - GlNewButton, + GlButton, GlFormGroup, GlFormSelect, GlLabel, }, currentUserAvatarUrl: gon.current_user_avatar_url, currentUsername: gon.current_username, + props: { + issuesPath: { + type: String, + required: true, + }, + jiraProjects: { + type: Array, + required: true, + }, + }, + data() { + return { + selectedOption: null, + selectState: null, + }; + }, + methods: { + initiateJiraImport(event) { + event.preventDefault(); + if (!this.selectedOption) { + this.showValidationError(); + } else { + this.hideValidationError(); + this.$emit('initiateJiraImport', this.selectedOption); + } + }, + hideValidationError() { + this.selectState = null; + }, + showValidationError() { + this.selectState = false; + }, + }, }; </script> @@ -19,14 +52,21 @@ export default { <div> <h3 class="page-title">{{ __('New Jira import') }}</h3> <hr /> - <form> + <form @submit="initiateJiraImport"> <gl-form-group class="row align-items-center" + :invalid-feedback="__('Please select a Jira project')" :label="__('Import from')" label-cols-sm="2" label-for="jira-project-select" > - <gl-form-select id="jira-project-select" class="mb-2" /> + <gl-form-select + id="jira-project-select" + v-model="selectedOption" + class="mb-2" + :options="jiraProjects" + :state="selectState" + /> </gl-form-group> <gl-form-group @@ -86,8 +126,10 @@ export default { </gl-form-group> <div class="footer-block row-content-block d-flex justify-content-between"> - <gl-new-button category="primary" variant="success">{{ __('Next') }}</gl-new-button> - <gl-new-button>{{ __('Cancel') }}</gl-new-button> + <gl-button type="submit" category="primary" variant="success" class="js-no-auto-disable"> + {{ __('Next') }} + </gl-button> + <gl-button :href="issuesPath">{{ __('Cancel') }}</gl-button> </div> </form> </div> diff --git a/app/assets/javascripts/jira_import/components/jira_import_progress.vue b/app/assets/javascripts/jira_import/components/jira_import_progress.vue new file mode 100644 index 00000000000..2d610224658 --- /dev/null +++ b/app/assets/javascripts/jira_import/components/jira_import_progress.vue @@ -0,0 +1,66 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { formatDate } from '~/lib/utils/datetime_utility'; +import { __, sprintf } from '~/locale'; + +export default { + name: 'JiraImportProgress', + components: { + GlEmptyState, + }, + props: { + illustration: { + type: String, + required: true, + }, + importInitiator: { + type: String, + required: true, + }, + importProject: { + type: String, + required: true, + }, + importTime: { + type: String, + required: true, + }, + issuesPath: { + type: String, + required: true, + }, + }, + computed: { + importInitiatorText() { + return sprintf(__('Import started by: %{importInitiator}'), { + importInitiator: this.importInitiator, + }); + }, + importProjectText() { + return sprintf(__('Jira project: %{importProject}'), { + importProject: this.importProject, + }); + }, + importTimeText() { + return sprintf(__('Time of import: %{importTime}'), { + importTime: formatDate(this.importTime), + }); + }, + }, +}; +</script> + +<template> + <gl-empty-state + :svg-path="illustration" + :title="__('Import in progress')" + :primary-button-text="__('View issues')" + :primary-button-link="issuesPath" + > + <template #description> + <p class="mb-0">{{ importInitiatorText }}</p> + <p class="mb-0">{{ importTimeText }}</p> + <p class="mb-0">{{ importProjectText }}</p> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/jira_import/components/jira_import_setup.vue b/app/assets/javascripts/jira_import/components/jira_import_setup.vue index 917930397f4..44773a773d5 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_setup.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_setup.vue @@ -1,6 +1,11 @@ <script> +import { GlEmptyState } from '@gitlab/ui'; + export default { name: 'JiraImportSetup', + components: { + GlEmptyState, + }, props: { illustration: { type: String, @@ -11,15 +16,11 @@ export default { </script> <template> - <div class="empty-state"> - <div class="svg-content"> - <img :src="illustration" :alt="__('Set up Jira Integration illustration')" /> - </div> - <div class="text-content d-flex flex-column align-items-center"> - <p>{{ __('You will first need to set up Jira Integration to use this feature.') }}</p> - <a class="btn btn-success" href="../services/jira/edit"> - {{ __('Set up Jira Integration') }} - </a> - </div> - </div> + <gl-empty-state + :svg-path="illustration" + title="" + :description="__('You will first need to set up Jira Integration to use this feature.')" + :primary-button-text="__('Set up Jira Integration')" + primary-button-link="../services/jira/edit" + /> </template> diff --git a/app/assets/javascripts/jira_import/index.js b/app/assets/javascripts/jira_import/index.js index 13b16b81c49..8bd70e4e277 100644 --- a/app/assets/javascripts/jira_import/index.js +++ b/app/assets/javascripts/jira_import/index.js @@ -24,7 +24,10 @@ export default function mountJiraImportApp() { render(createComponent) { return createComponent(App, { props: { + inProgressIllustration: el.dataset.inProgressIllustration, isJiraConfigured: parseBoolean(el.dataset.isJiraConfigured), + issuesPath: el.dataset.issuesPath, + jiraProjects: el.dataset.jiraProjects ? JSON.parse(el.dataset.jiraProjects) : [], projectPath: el.dataset.projectPath, setupIllustration: el.dataset.setupIllustration, }, diff --git a/app/assets/javascripts/jira_import/queries/getJiraProjects.query.graphql b/app/assets/javascripts/jira_import/queries/getJiraProjects.query.graphql deleted file mode 100644 index 13100eac221..00000000000 --- a/app/assets/javascripts/jira_import/queries/getJiraProjects.query.graphql +++ /dev/null @@ -1,14 +0,0 @@ -query getJiraProjects($fullPath: ID!) { - project(fullPath: $fullPath) { - jiraImportStatus - jiraImports { - nodes { - jiraProjectKey - scheduledAt - scheduledBy { - username - } - } - } - } -} diff --git a/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql new file mode 100644 index 00000000000..0eaaad580fc --- /dev/null +++ b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql @@ -0,0 +1,12 @@ +#import "./jira_import.fragment.graphql" + +query($fullPath: ID!) { + project(fullPath: $fullPath) { + jiraImportStatus + jiraImports(last: 1) { + nodes { + ...JiraImport + } + } + } +} diff --git a/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql b/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql new file mode 100644 index 00000000000..8fda8287988 --- /dev/null +++ b/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql @@ -0,0 +1,11 @@ +#import "./jira_import.fragment.graphql" + +mutation($input: JiraImportStartInput!) { + jiraImportStart(input: $input) { + clientMutationId + jiraImport { + ...JiraImport + } + errors + } +} diff --git a/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql b/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql new file mode 100644 index 00000000000..fde2ebeff91 --- /dev/null +++ b/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql @@ -0,0 +1,7 @@ +fragment JiraImport on JiraImport { + jiraProjectKey + scheduledAt + scheduledBy { + name + } +} diff --git a/app/assets/javascripts/jira_import/utils.js b/app/assets/javascripts/jira_import/utils.js new file mode 100644 index 00000000000..504cf19e44e --- /dev/null +++ b/app/assets/javascripts/jira_import/utils.js @@ -0,0 +1,10 @@ +export const IMPORT_STATE = { + FAILED: 'failed', + FINISHED: 'finished', + NONE: 'none', + SCHEDULED: 'scheduled', + STARTED: 'started', +}; + +export const isInProgress = state => + state === IMPORT_STATE.SCHEDULED || state === IMPORT_STATE.STARTED; diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js index 9e8edd05b88..a464290ffb5 100644 --- a/app/assets/javascripts/lazy_loader.js +++ b/app/assets/javascripts/lazy_loader.js @@ -1,4 +1,4 @@ -import _ from 'underscore'; +import { debounce, throttle } from 'lodash'; export const placeholderImage = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; @@ -82,7 +82,7 @@ export default class LazyLoader { } startIntersectionObserver = () => { - this.throttledElementsInView = _.throttle(() => this.checkElementsInView(), 300); + this.throttledElementsInView = throttle(() => this.checkElementsInView(), 300); this.intersectionObserver = new IntersectionObserver(this.onIntersection, { rootMargin: `${SCROLL_THRESHOLD}px 0px`, thresholds: 0.1, @@ -102,8 +102,8 @@ export default class LazyLoader { }; startLegacyObserver() { - this.throttledScrollCheck = _.throttle(() => this.scrollCheck(), 300); - this.debouncedElementsInView = _.debounce(() => this.checkElementsInView(), 300); + this.throttledScrollCheck = throttle(() => this.scrollCheck(), 300); + this.debouncedElementsInView = debounce(() => this.checkElementsInView(), 300); window.addEventListener('scroll', this.throttledScrollCheck); window.addEventListener('resize', this.debouncedElementsInView); } diff --git a/app/assets/javascripts/lib/utils/keycodes.js b/app/assets/javascripts/lib/utils/keycodes.js index 2270d329c24..16bffc5c2cf 100644 --- a/app/assets/javascripts/lib/utils/keycodes.js +++ b/app/assets/javascripts/lib/utils/keycodes.js @@ -1,5 +1,6 @@ -export const UP_KEY_CODE = 38; -export const DOWN_KEY_CODE = 40; +export const BACKSPACE_KEY_CODE = 8; export const ENTER_KEY_CODE = 13; export const ESC_KEY_CODE = 27; -export const BACKSPACE_KEY_CODE = 8; +export const UP_KEY_CODE = 38; +export const DOWN_KEY_CODE = 40; +export const DELETE_KEY_CODE = 46; diff --git a/app/assets/javascripts/lib/utils/unit_format/index.js b/app/assets/javascripts/lib/utils/unit_format/index.js index d3aea37e677..adf374db66c 100644 --- a/app/assets/javascripts/lib/utils/unit_format/index.js +++ b/app/assets/javascripts/lib/utils/unit_format/index.js @@ -1,3 +1,4 @@ +import { engineeringNotation } from '@gitlab/ui/src/utils/number_utils'; import { s__ } from '~/locale'; import { @@ -39,15 +40,18 @@ export const SUPPORTED_FORMATS = { gibibytes: 'gibibytes', tebibytes: 'tebibytes', pebibytes: 'pebibytes', + + // Engineering Notation + engineering: 'engineering', }; /** * Returns a function that formats number to different units - * @param {String} format - Format to use, must be one of the SUPPORTED_FORMATS. Defaults to number. + * @param {String} format - Format to use, must be one of the SUPPORTED_FORMATS. Defaults to engineering notation. * * */ -export const getFormatter = (format = SUPPORTED_FORMATS.number) => { +export const getFormatter = (format = SUPPORTED_FORMATS.engineering) => { // Number if (format === SUPPORTED_FORMATS.number) { @@ -252,6 +256,17 @@ export const getFormatter = (format = SUPPORTED_FORMATS.number) => { return scaledBinaryFormatter('B', 5); } + if (format === SUPPORTED_FORMATS.engineering) { + /** + * Formats via engineering notation + * + * @function + * @param {Number} value - Value to format + * @param {Number} fractionDigits - precision decimals - Defaults to 2 + */ + return engineeringNotation; + } + // Fail so client library addresses issue throw TypeError(`${format} is not a valid number format`); }; diff --git a/app/assets/javascripts/logs/constants.js b/app/assets/javascripts/logs/constants.js index 450b83f4827..51770aa7a1c 100644 --- a/app/assets/javascripts/logs/constants.js +++ b/app/assets/javascripts/logs/constants.js @@ -1,3 +1,3 @@ -export const dateFormatMask = 'UTC:mmm dd HH:MM:ss.l"Z"'; +export const dateFormatMask = 'mmm dd HH:MM:ss.l'; export const TOKEN_TYPE_POD_NAME = 'TOKEN_TYPE_POD_NAME'; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 81b2e9f13a5..6c8f6372795 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -298,6 +298,18 @@ document.addEventListener('DOMContentLoaded', () => { if ($gutterIcon.hasClass('fa-angle-double-right')) { $sidebarGutterToggle.trigger('click'); } + + const sidebarGutterVueToggleEl = document.querySelector('.js-sidebar-vue-toggle'); + + // Sidebar has an icon which corresponds to collapsing the sidebar + // only then trigger the click. + if (sidebarGutterVueToggleEl) { + const collapseIcon = sidebarGutterVueToggleEl.querySelector('i.fa-angle-double-right'); + + if (collapseIcon) { + collapseIcon.click(); + } + } } }); diff --git a/app/assets/javascripts/monitoring/components/charts/annotations.js b/app/assets/javascripts/monitoring/components/charts/annotations.js index 947750b3721..418107c4126 100644 --- a/app/assets/javascripts/monitoring/components/charts/annotations.js +++ b/app/assets/javascripts/monitoring/components/charts/annotations.js @@ -1,20 +1,20 @@ -import { graphTypes, symbolSizes, colorValues } from '../../constants'; +import { graphTypes, symbolSizes, colorValues, annotationsSymbolIcon } from '../../constants'; /** * Annotations and deployments are decoration layers on * top of the actual chart data. We use a scatter plot to * display this information. Each chart has its coordinate - * system based on data and irresptive of the data, these + * system based on data and irrespective of the data, these * decorations have to be placed in specific locations. * For this reason, annotations have their own coordinate system, * * As of %12.9, only deployment icons, a type of annotations, need * to be displayed on the chart. * - * After https://gitlab.com/gitlab-org/gitlab/-/issues/211418, - * annotations and deployments will co-exist in the same - * series as they logically belong together. Annotations will be - * passed as markLine objects. + * Annotations and deployments co-exist in the same series as + * they logically belong together. Annotations are passed as + * markLines and markPoints while deployments are passed as + * data points with custom icons. */ /** @@ -45,42 +45,49 @@ export const annotationsYAxis = { * Fetched list of annotations are parsed into a * format the eCharts accepts to draw markLines * - * If Annotation is a single line, the `starting_at` property - * has a value and the `ending_at` is null. Because annotations - * only supports lines the `ending_at` value does not exist yet. - * + * If Annotation is a single line, the `startingAt` property + * has a value and the `endingAt` is null. Because annotations + * only supports lines the `endingAt` value does not exist yet. * * @param {Object} annotation object * @returns {Object} markLine object */ -export const parseAnnotations = ({ starting_at = '', color = colorValues.primaryColor }) => ({ - xAxis: starting_at, - lineStyle: { - color, - }, -}); +export const parseAnnotations = annotations => + annotations.reduce( + (acc, annotation) => { + acc.lines.push({ + xAxis: annotation.startingAt, + lineStyle: { + color: colorValues.primaryColor, + }, + }); + + acc.points.push({ + name: 'annotations', + xAxis: annotation.startingAt, + yAxis: annotationsYAxisCoords.min, + tooltipData: { + title: annotation.startingAt, + content: annotation.description, + }, + }); + + return acc; + }, + { lines: [], points: [] }, + ); /** - * This method currently generates deployments and annotations - * but are not used in the chart. The method calling - * generateAnnotationsSeries will not pass annotations until - * https://gitlab.com/gitlab-org/gitlab/-/issues/211330 is - * implemented. - * - * This method is extracted out of the charts so that - * annotation lines can be easily supported in - * the future. - * - * In order to make hover work, hidden annotation data points - * are created along with the markLines. These data points have - * the necessart metadata that is used to display in the tooltip. + * This method generates a decorative series that has + * deployments as data points with custom icons and + * annotations as markLines and markPoints * * @param {Array} deployments deployments data * @returns {Object} annotation series object */ export const generateAnnotationsSeries = ({ deployments = [], annotations = [] } = {}) => { // deployment data points - const deploymentsData = deployments.map(deployment => { + const data = deployments.map(deployment => { return { name: 'deployments', value: [deployment.createdAt, annotationsYAxisCoords.pos], @@ -98,31 +105,29 @@ export const generateAnnotationsSeries = ({ deployments = [], annotations = [] } }; }); - // annotation data points - const annotationsData = annotations.map(annotation => { - return { - name: 'annotations', - value: [annotation.starting_at, annotationsYAxisCoords.pos], - // style options - symbol: 'none', - // metadata that are accessible in `formatTooltipText` method - tooltipData: { - description: annotation.description, - }, - }; - }); + const parsedAnnotations = parseAnnotations(annotations); - // annotation markLine option + // markLine option draws the annotations dotted line const markLine = { symbol: 'none', silent: true, - data: annotations.map(parseAnnotations), + data: parsedAnnotations.lines, + }; + + // markPoints are the arrows under the annotations lines + const markPoint = { + symbol: annotationsSymbolIcon, + symbolSize: '8', + symbolOffset: [0, ' 60%'], + data: parsedAnnotations.points, }; return { + name: 'annotations', type: graphTypes.annotationsData, yAxisIndex: 1, // annotationsYAxis index - data: [...deploymentsData, ...annotationsData], + data, markLine, + markPoint, }; }; diff --git a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue index 5588d9ac060..e015ef32d8c 100644 --- a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue +++ b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue @@ -3,12 +3,6 @@ import chartEmptyStateIllustration from '@gitlab/svgs/dist/illustrations/chart-e import { chartHeight } from '../../constants'; export default { - props: { - graphTitle: { - type: String, - required: true, - }, - }, data() { return { height: chartHeight, diff --git a/app/assets/javascripts/monitoring/components/charts/options.js b/app/assets/javascripts/monitoring/components/charts/options.js index d9f49bd81f5..09b03774580 100644 --- a/app/assets/javascripts/monitoring/components/charts/options.js +++ b/app/assets/javascripts/monitoring/components/charts/options.js @@ -6,9 +6,8 @@ const yAxisBoundaryGap = [0.1, 0.1]; * Max string length of formatted axis tick */ const maxDataAxisTickLength = 8; - // Defaults -const defaultFormat = SUPPORTED_FORMATS.number; +const defaultFormat = SUPPORTED_FORMATS.engineering; const defaultYAxisFormat = defaultFormat; const defaultYAxisPrecision = 2; @@ -26,8 +25,7 @@ const chartGridLeft = 75; * @param {Object} param - Dashboard .yml definition options */ const getDataAxisOptions = ({ format, precision, name }) => { - const formatter = getFormatter(format); - + const formatter = getFormatter(format); // default to engineeringNotation, same as gitlab-ui return { name, nameLocation: 'center', // same as gitlab-ui's default diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index 9041b01088c..bf40e8f448e 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -6,7 +6,7 @@ import dateFormat from 'dateformat'; import { s__, __ } from '~/locale'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import Icon from '~/vue_shared/components/icon.vue'; -import { chartHeight, lineTypes, lineWidths, dateFormats, tooltipTypes } from '../../constants'; +import { chartHeight, lineTypes, lineWidths, dateFormats } from '../../constants'; import { getYAxisOptions, getChartGrid, getTooltipFormatter } from './options'; import { annotationsYAxis, generateAnnotationsSeries } from './annotations'; import { makeDataSeries } from '~/helpers/monitor_helper'; @@ -20,7 +20,6 @@ const events = { }; export default { - tooltipTypes, components: { GlAreaChart, GlLineChart, @@ -262,6 +261,21 @@ export default { isTooltipOfType(tooltipType, defaultType) { return tooltipType === defaultType; }, + /** + * This method is triggered when hovered over a single markPoint. + * + * The annotations title timestamp should match the data tooltip + * title. + * + * @params {Object} params markPoint object + * @returns {Object} + */ + formatAnnotationsTooltipText(params) { + return { + title: dateFormat(params.data?.tooltipData?.title, dateFormats.default), + content: params.data?.tooltipData?.content, + }; + }, formatTooltipText(params) { this.tooltip.title = dateFormat(params.value, dateFormats.default); this.tooltip.content = []; @@ -270,15 +284,10 @@ export default { if (dataPoint.value) { const [, yVal] = dataPoint.value; this.tooltip.type = dataPoint.name; - if (this.isTooltipOfType(this.tooltip.type, this.$options.tooltipTypes.deployments)) { + if (this.tooltip.type === 'deployments') { const { data = {} } = dataPoint; this.tooltip.sha = data?.tooltipData?.sha; this.tooltip.commitUrl = data?.tooltipData?.commitUrl; - } else if ( - this.isTooltipOfType(this.tooltip.type, this.$options.tooltipTypes.annotations) - ) { - const { data } = dataPoint; - this.tooltip.content.push(data?.tooltipData?.description); } else { const { seriesName, color, dataIndex } = dataPoint; @@ -356,6 +365,7 @@ export default { :data="chartData" :option="chartOptions" :format-tooltip-text="formatTooltipText" + :format-annotations-tooltip-text="formatAnnotationsTooltipText" :thresholds="thresholds" :width="width" :height="height" @@ -364,7 +374,7 @@ export default { @created="onChartCreated" @updated="onChartUpdated" > - <template v-if="isTooltipOfType(tooltip.type, this.$options.tooltipTypes.deployments)"> + <template v-if="tooltip.type === 'deployments'"> <template slot="tooltipTitle"> {{ __('Deployed') }} </template> @@ -373,16 +383,6 @@ export default { <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link> </div> </template> - <template v-else-if="isTooltipOfType(tooltip.type, this.$options.tooltipTypes.annotations)"> - <template slot="tooltipTitle"> - <div class="text-nowrap"> - {{ tooltip.title }} - </div> - </template> - <div slot="tooltipContent" class="d-flex align-items-center"> - {{ tooltip.content.join('\n') }} - </div> - </template> <template v-else> <template slot="tooltipTitle"> <div class="text-nowrap"> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 4586ce70ad6..4d60b02d0df 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -8,7 +8,6 @@ import { GlDropdownItem, GlDropdownHeader, GlDropdownDivider, - GlFormGroup, GlModal, GlLoadingIcon, GlSearchBoxByType, @@ -19,6 +18,7 @@ import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; import { s__ } from '~/locale'; import createFlash from '~/flash'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue'; import { mergeUrlParams, redirectTo, updateHistory } from '~/lib/utils/url_utility'; import invalidUrl from '~/lib/utils/invalid_url'; import Icon from '~/vue_shared/components/icon.vue'; @@ -46,8 +46,8 @@ export default { GlDropdownHeader, GlDropdownDivider, GlSearchBoxByType, - GlFormGroup, GlModal, + CustomMetricsFormFields, DateTimePicker, GraphGroup, @@ -206,9 +206,6 @@ export default { }; }, computed: { - canAddMetrics() { - return this.customMetricsAvailable && this.customMetricsPath.length; - }, ...mapState('monitoringDashboard', [ 'dashboard', 'emptyState', @@ -229,7 +226,11 @@ export default { return !this.showEmptyState && this.rearrangePanelsAvailable; }, addingMetricsAvailable() { - return IS_EE && this.canAddMetrics && !this.showEmptyState; + return ( + this.customMetricsAvailable && + !this.showEmptyState && + this.firstDashboard === this.selectedDashboard + ); }, hasHeaderButtons() { return ( @@ -378,177 +379,164 @@ export default { <div v-if="showHeader" ref="prometheusGraphsHeader" - class="prometheus-graphs-header gl-p-3 pb-0 border-bottom bg-gray-light" + class="prometheus-graphs-header d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 border-bottom bg-gray-light" > - <div class="row"> - <gl-form-group - :label="__('Dashboard')" - label-size="sm" - label-for="monitor-dashboards-dropdown" - class="col-sm-12 col-md-6 col-lg-2" - > - <dashboards-dropdown - id="monitor-dashboards-dropdown" - data-qa-selector="dashboards_filter_dropdown" - class="mb-0 d-flex" - toggle-class="dropdown-menu-toggle" - :default-branch="defaultBranch" - :selected-dashboard="selectedDashboard" - @selectDashboard="selectDashboard($event)" - /> - </gl-form-group> + <div class="mb-2 pr-2 d-flex d-sm-block"> + <dashboards-dropdown + id="monitor-dashboards-dropdown" + data-qa-selector="dashboards_filter_dropdown" + class="flex-grow-1" + toggle-class="dropdown-menu-toggle" + :default-branch="defaultBranch" + :selected-dashboard="selectedDashboard" + @selectDashboard="selectDashboard($event)" + /> + </div> - <gl-form-group - :label="s__('Metrics|Environment')" - label-size="sm" - label-for="monitor-environments-dropdown" - class="col-sm-6 col-md-6 col-lg-2" + <div class="mb-2 pr-2 d-flex d-sm-block"> + <gl-dropdown + id="monitor-environments-dropdown" + ref="monitorEnvironmentsDropdown" + class="flex-grow-1" + data-qa-selector="environments_dropdown" + toggle-class="dropdown-menu-toggle" + menu-class="monitor-environment-dropdown-menu" + :text="currentEnvironmentName" > - <gl-dropdown - id="monitor-environments-dropdown" - ref="monitorEnvironmentsDropdown" - data-qa-selector="environments_dropdown" - class="mb-0 d-flex" - toggle-class="dropdown-menu-toggle" - menu-class="monitor-environment-dropdown-menu" - :text="currentEnvironmentName" - > - <div class="d-flex flex-column overflow-hidden"> - <gl-dropdown-header class="monitor-environment-dropdown-header text-center">{{ - __('Environment') - }}</gl-dropdown-header> - <gl-dropdown-divider /> - <gl-search-box-by-type - ref="monitorEnvironmentsDropdownSearch" - class="m-2" - @input="debouncedEnvironmentsSearch" - /> - <gl-loading-icon - v-if="environmentsLoading" - ref="monitorEnvironmentsDropdownLoading" - :inline="true" - /> - <div v-else class="flex-fill overflow-auto"> - <gl-dropdown-item - v-for="environment in filteredEnvironments" - :key="environment.id" - :active="environment.name === currentEnvironmentName" - active-class="is-active" - :href="environment.metrics_path" - >{{ environment.name }}</gl-dropdown-item - > - </div> - <div - v-show="shouldShowEnvironmentsDropdownNoMatchedMsg" - ref="monitorEnvironmentsDropdownMsg" - class="text-secondary no-matches-message" + <div class="d-flex flex-column overflow-hidden"> + <gl-dropdown-header class="monitor-environment-dropdown-header text-center"> + {{ __('Environment') }} + </gl-dropdown-header> + <gl-dropdown-divider /> + <gl-search-box-by-type + ref="monitorEnvironmentsDropdownSearch" + class="m-2" + @input="debouncedEnvironmentsSearch" + /> + <gl-loading-icon + v-if="environmentsLoading" + ref="monitorEnvironmentsDropdownLoading" + :inline="true" + /> + <div v-else class="flex-fill overflow-auto"> + <gl-dropdown-item + v-for="environment in filteredEnvironments" + :key="environment.id" + :active="environment.name === currentEnvironmentName" + active-class="is-active" + :href="environment.metrics_path" + >{{ environment.name }}</gl-dropdown-item > - {{ __('No matching results') }} - </div> </div> - </gl-dropdown> - </gl-form-group> + <div + v-show="shouldShowEnvironmentsDropdownNoMatchedMsg" + ref="monitorEnvironmentsDropdownMsg" + class="text-secondary no-matches-message" + > + {{ __('No matching results') }} + </div> + </div> + </gl-dropdown> + </div> - <gl-form-group - :label="s__('Metrics|Show last')" - label-size="sm" - label-for="monitor-time-window-dropdown" - class="col-sm-auto col-md-auto col-lg-auto" + <div class="mb-2 pr-2 d-flex d-sm-block"> + <date-time-picker + ref="dateTimePicker" + class="flex-grow-1 show-last-dropdown" data-qa-selector="show_last_dropdown" + :value="selectedTimeRange" + :options="timeRanges" + @input="onDateTimePickerInput" + @invalid="onDateTimePickerInvalid" + /> + </div> + + <div class="mb-2 pr-2 d-flex d-sm-block"> + <gl-deprecated-button + ref="refreshDashboardBtn" + v-gl-tooltip + class="flex-grow-1" + variant="default" + :title="s__('Metrics|Refresh dashboard')" + @click="refreshDashboard" > - <date-time-picker - ref="dateTimePicker" - :value="selectedTimeRange" - :options="timeRanges" - @input="onDateTimePickerInput" - @invalid="onDateTimePickerInvalid" - /> - </gl-form-group> + <icon name="retry" /> + </gl-deprecated-button> + </div> + + <div class="flex-grow-1"></div> - <gl-form-group class="col-sm-2 col-md-2 col-lg-1 refresh-dashboard-button"> + <div class="d-sm-flex"> + <div v-if="showRearrangePanelsBtn" class="mb-2 mr-2 d-flex"> <gl-deprecated-button - ref="refreshDashboardBtn" - v-gl-tooltip + :pressed="isRearrangingPanels" variant="default" - :title="s__('Metrics|Refresh dashboard')" - @click="refreshDashboard" + class="flex-grow-1 js-rearrange-button" + @click="toggleRearrangingPanels" > - <icon name="retry" /> + {{ __('Arrange charts') }} </gl-deprecated-button> - </gl-form-group> - - <gl-form-group - v-if="hasHeaderButtons" - label-for="prometheus-graphs-dropdown-buttons" - class="dropdown-buttons col-md d-md-flex col-lg d-lg-flex align-items-end" - > - <div id="prometheus-graphs-dropdown-buttons"> - <gl-deprecated-button - v-if="showRearrangePanelsBtn" - :pressed="isRearrangingPanels" - variant="default" - class="mr-2 mt-1 js-rearrange-button" - @click="toggleRearrangingPanels" - >{{ __('Arrange charts') }}</gl-deprecated-button - > - <gl-deprecated-button - v-if="addingMetricsAvailable" - ref="addMetricBtn" - v-gl-modal="$options.addMetric.modalId" - variant="outline-success" - data-qa-selector="add_metric_button" - class="mr-2 mt-1" - >{{ $options.addMetric.title }}</gl-deprecated-button - > - <gl-modal - v-if="addingMetricsAvailable" - ref="addMetricModal" - :modal-id="$options.addMetric.modalId" - :title="$options.addMetric.title" - > - <form ref="customMetricsForm" :action="customMetricsPath" method="post"> - <custom-metrics-form-fields - :validate-query-path="validateQueryPath" - form-operation="post" - @formValidation="setFormValidity" - /> - </form> - <div slot="modal-footer"> - <gl-deprecated-button @click="hideAddMetricModal">{{ - __('Cancel') - }}</gl-deprecated-button> - <gl-deprecated-button - ref="submitCustomMetricsFormBtn" - v-track-event="getAddMetricTrackingOptions()" - :disabled="!formIsValid" - variant="success" - @click="submitCustomMetricsForm" - >{{ __('Save changes') }}</gl-deprecated-button - > - </div> - </gl-modal> + </div> + <div v-if="addingMetricsAvailable" class="mb-2 mr-2 d-flex d-sm-block"> + <gl-deprecated-button + ref="addMetricBtn" + v-gl-modal="$options.addMetric.modalId" + variant="outline-success" + data-qa-selector="add_metric_button" + class="flex-grow-1" + > + {{ $options.addMetric.title }} + </gl-deprecated-button> + <gl-modal + ref="addMetricModal" + :modal-id="$options.addMetric.modalId" + :title="$options.addMetric.title" + > + <form ref="customMetricsForm" :action="customMetricsPath" method="post"> + <custom-metrics-form-fields + :validate-query-path="validateQueryPath" + form-operation="post" + @formValidation="setFormValidity" + /> + </form> + <div slot="modal-footer"> + <gl-deprecated-button @click="hideAddMetricModal"> + {{ __('Cancel') }} + </gl-deprecated-button> + <gl-deprecated-button + ref="submitCustomMetricsFormBtn" + v-track-event="getAddMetricTrackingOptions()" + :disabled="!formIsValid" + variant="success" + @click="submitCustomMetricsForm" + > + {{ __('Save changes') }} + </gl-deprecated-button> + </div> + </gl-modal> + </div> - <gl-deprecated-button - v-if="selectedDashboard.can_edit" - class="mt-1 js-edit-link" - :href="selectedDashboard.project_blob_path" - data-qa-selector="edit_dashboard_button" - >{{ __('Edit dashboard') }}</gl-deprecated-button - > + <div v-if="selectedDashboard.can_edit" class="mb-2 mr-2 d-flex d-sm-block"> + <gl-deprecated-button + class="flex-grow-1 js-edit-link" + :href="selectedDashboard.project_blob_path" + data-qa-selector="edit_dashboard_button" + > + {{ __('Edit dashboard') }} + </gl-deprecated-button> + </div> - <gl-deprecated-button - v-if="externalDashboardUrl.length" - class="mt-1 js-external-dashboard-link" - variant="primary" - :href="externalDashboardUrl" - target="_blank" - rel="noopener noreferrer" - > - {{ __('View full dashboard') }} - <icon name="external-link" /> - </gl-deprecated-button> - </div> - </gl-form-group> + <div v-if="externalDashboardUrl.length" class="mb-2 mr-2 d-flex d-sm-block"> + <gl-deprecated-button + class="flex-grow-1 js-external-dashboard-link" + variant="primary" + :href="externalDashboardUrl" + target="_blank" + rel="noopener noreferrer" + > + {{ __('View full dashboard') }} <icon name="external-link" /> + </gl-deprecated-button> + </div> </div> </div> diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue index 676fc0cca64..2beae0d9540 100644 --- a/app/assets/javascripts/monitoring/components/panel_type.vue +++ b/app/assets/javascripts/monitoring/components/panel_type.vue @@ -4,6 +4,7 @@ import { pickBy } from 'lodash'; import invalidUrl from '~/lib/utils/invalid_url'; import { GlResizeObserverDirective, + GlIcon, GlLoadingIcon, GlDropdown, GlDropdownItem, @@ -13,7 +14,6 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import { __, n__ } from '~/locale'; -import Icon from '~/vue_shared/components/icon.vue'; import MonitorTimeSeriesChart from './charts/time_series.vue'; import MonitorAnomalyChart from './charts/anomaly.vue'; import MonitorSingleStatChart from './charts/single_stat.vue'; @@ -37,7 +37,7 @@ export default { MonitorHeatmapChart, MonitorStackedColumnChart, MonitorEmptyChart, - Icon, + GlIcon, GlLoadingIcon, GlTooltip, GlDropdown, @@ -227,7 +227,7 @@ export default { </div> <div v-if="isContextualMenuShown" - class="js-graph-widgets" + ref="contextualMenu" data-qa-selector="prometheus_graph_widgets" > <div class="d-flex align-items-center"> @@ -240,7 +240,7 @@ export default { :title="__('More actions')" > <template slot="button-content"> - <icon name="ellipsis_v" class="text-secondary" /> + <gl-icon name="ellipsis_v" class="text-secondary" /> </template> <gl-dropdown-item v-if="editCustomMetricLink" @@ -319,6 +319,6 @@ export default { :group-id="groupId" @datazoom="onDatazoom" /> - <monitor-empty-chart v-else :graph-title="title" v-bind="$attrs" v-on="$listeners" /> + <monitor-empty-chart v-else v-bind="$attrs" v-on="$listeners" /> </div> </template> diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index 8d821c27099..0b393f19789 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -120,10 +120,26 @@ export const NOT_IN_DB_PREFIX = 'NO_DB'; export const ENVIRONMENT_AVAILABLE_STATE = 'available'; /** - * Time series charts have different types of - * tooltip based on the hovered data point. + * As of %12.10, the svg icon library does not have an annotation + * arrow icon yet. In order to deliver annotations feature, the icon + * is hard coded until the icon is added. The below issue is + * to track the icon. + * + * https://gitlab.com/gitlab-org/gitlab-svgs/-/issues/118 + * + * Once the icon is merged this can be removed. + * https://gitlab.com/gitlab-org/gitlab/-/issues/214540 */ -export const tooltipTypes = { - deployments: 'deployments', - annotations: 'annotations', -}; +export const annotationsSymbolIcon = 'path://m5 229 5 8h-10z'; + +/** + * As of %12.10, dashboard path is required to create annotation. + * The FE gets the dashboard name from the URL params. It is not + * ideal to store the path this way but there is no other way to + * get this path unless annotations fetch is delayed. This could + * potentially be removed and have the backend send this to the FE. + * + * This technical debt is being tracked here + * https://gitlab.com/gitlab-org/gitlab/-/issues/214671 + */ +export const DEFAULT_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml'; diff --git a/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql b/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql index 2fd698eadf9..27b49860b8a 100644 --- a/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql +++ b/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql @@ -1,12 +1,25 @@ -query getAnnotations($projectPath: ID!) { - environment(name: $environmentName) { - metricDashboard(id: $dashboardId) { - annotations: nodes { +query getAnnotations( + $projectPath: ID! + $environmentName: String + $dashboardPath: String! + $startingFrom: Time! +) { + project(fullPath: $projectPath) { + environments(name: $environmentName) { + nodes { id - description - starting_at - ending_at - panelId + name + metricsDashboard(path: $dashboardPath) { + annotations(from: $startingFrom) { + nodes { + id + description + startingAt + endingAt + panelId + } + } + } } } } diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 5b2bd1f1493..f04f775761c 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -3,7 +3,12 @@ import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; -import { gqClient, parseEnvironmentsResponse, removeLeadingSlash } from './utils'; +import { + gqClient, + parseEnvironmentsResponse, + parseAnnotationsResponse, + removeLeadingSlash, +} from './utils'; import trackDashboardLoad from '../monitoring_tracking_helper'; import getEnvironments from '../queries/getEnvironments.query.graphql'; import getAnnotations from '../queries/getAnnotations.query.graphql'; @@ -15,7 +20,11 @@ import { } from '../../lib/utils/common_utils'; import { s__, sprintf } from '../../locale'; -import { PROMETHEUS_TIMEOUT, ENVIRONMENT_AVAILABLE_STATE } from '../constants'; +import { + PROMETHEUS_TIMEOUT, + ENVIRONMENT_AVAILABLE_STATE, + DEFAULT_DASHBOARD_PATH, +} from '../constants'; function prometheusMetricQueryParams(timeRange) { const { start, end } = convertToFixedRange(timeRange); @@ -90,7 +99,7 @@ export const fetchData = ({ dispatch }) => { * ready after the BE piece is implemented. * https://gitlab.com/gitlab-org/gitlab/-/issues/211330 */ - if (isFeatureFlagEnabled('metrics_dashboard_annotations')) { + if (isFeatureFlagEnabled('metricsDashboardAnnotations')) { dispatch('fetchAnnotations'); } }; @@ -283,18 +292,21 @@ export const receiveEnvironmentsDataFailure = ({ commit }) => { }; export const fetchAnnotations = ({ state, dispatch }) => { - dispatch('requestAnnotations'); - + const { start } = convertToFixedRange(state.timeRange); + const dashboardPath = + state.currentDashboard === '' ? DEFAULT_DASHBOARD_PATH : state.currentDashboard; return gqClient .mutate({ mutation: getAnnotations, variables: { projectPath: removeLeadingSlash(state.projectPath), - dashboardId: state.currentDashboard, environmentName: state.currentEnvironmentName, + dashboardPath, + startingFrom: start, }, }) - .then(resp => resp.data?.project?.environment?.metricDashboard?.annotations) + .then(resp => resp.data?.project?.environments?.nodes?.[0].metricsDashboard?.annotations.nodes) + .then(parseAnnotationsResponse) .then(annotations => { if (!annotations) { createFlash(s__('Metrics|There was an error fetching annotations. Please try again.')); @@ -309,9 +321,6 @@ export const fetchAnnotations = ({ state, dispatch }) => { }); }; -// While this commit does not update the state it will -// eventually be useful to show a loading state -export const requestAnnotations = ({ commit }) => commit(types.REQUEST_ANNOTATIONS); export const receiveAnnotationsSuccess = ({ commit }, data) => commit(types.RECEIVE_ANNOTATIONS_SUCCESS, data); export const receiveAnnotationsFailure = ({ commit }) => commit(types.RECEIVE_ANNOTATIONS_FAILURE); diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index 2f9955da1b1..27a9a67edaa 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -4,7 +4,6 @@ export const RECEIVE_METRICS_DASHBOARD_SUCCESS = 'RECEIVE_METRICS_DASHBOARD_SUCC export const RECEIVE_METRICS_DASHBOARD_FAILURE = 'RECEIVE_METRICS_DASHBOARD_FAILURE'; // Annotations -export const REQUEST_ANNOTATIONS = 'REQUEST_ANNOTATIONS'; export const RECEIVE_ANNOTATIONS_SUCCESS = 'RECEIVE_ANNOTATIONS_SUCCESS'; export const RECEIVE_ANNOTATIONS_FAILURE = 'RECEIVE_ANNOTATIONS_FAILURE'; diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index a212e9be703..9f06d18c46f 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -58,6 +58,31 @@ export const parseEnvironmentsResponse = (response = [], projectPath) => }); /** + * Annotation API returns time in UTC. This method + * converts time to local time. + * + * startingAt always exists but endingAt does not. + * If endingAt does not exist, a threshold line is + * drawn. + * + * If endingAt exists, a threshold range is drawn. + * But this is not supported as of %12.10 + * + * @param {Array} response annotations response + * @returns {Array} parsed responses + */ +export const parseAnnotationsResponse = response => { + if (!response) { + return []; + } + return response.map(annotation => ({ + ...annotation, + startingAt: new Date(annotation.startingAt), + endingAt: annotation.endingAt ? new Date(annotation.endingAt) : null, + })); +}; + +/** * Maps metrics to its view model * * This function difers from other in that is maps all @@ -95,15 +120,19 @@ const mapXAxisToViewModel = ({ name = '' }) => ({ name }); /** * Maps Y-axis view model * - * Defaults to a 2 digit precision and `number` format. It only allows + * Defaults to a 2 digit precision and `engineering` format. It only allows * formats in the SUPPORTED_FORMATS array. * * @param {Object} axis */ -const mapYAxisToViewModel = ({ name = '', format = SUPPORTED_FORMATS.number, precision = 2 }) => { +const mapYAxisToViewModel = ({ + name = '', + format = SUPPORTED_FORMATS.engineering, + precision = 2, +}) => { return { name, - format: SUPPORTED_FORMATS[format] || SUPPORTED_FORMATS.number, + format: SUPPORTED_FORMATS[format] || SUPPORTED_FORMATS.engineering, precision, }; }; diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index df62e379017..5181b5f26ee 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -1,17 +1,12 @@ <script> import { mapActions, mapGetters } from 'vuex'; -import tooltip from '~/vue_shared/directives/tooltip'; -import Icon from '~/vue_shared/components/icon.vue'; +import AwardsList from '~/vue_shared/components/awards_list.vue'; import Flash from '../../flash'; -import { glEmojiTag } from '../../emoji'; -import { __, sprintf } from '~/locale'; +import { __ } from '~/locale'; export default { components: { - Icon, - }, - directives: { - tooltip, + AwardsList, }, props: { awards: { @@ -37,130 +32,20 @@ export default { }, computed: { ...mapGetters(['getUserData']), - // `this.awards` is an array with emojis but they are not grouped by emoji name. See below. - // [ { name: foo, user: user1 }, { name: bar, user: user1 }, { name: foo, user: user2 } ] - // This method will group emojis by their name as an Object. See below. - // { - // foo: [ { name: foo, user: user1 }, { name: foo, user: user2 } ], - // bar: [ { name: bar, user: user1 } ] - // } - // We need to do this otherwise we will render the same emoji over and over again. - groupedAwards() { - const awards = this.awards.reduce((acc, award) => { - if (Object.prototype.hasOwnProperty.call(acc, award.name)) { - acc[award.name].push(award); - } else { - Object.assign(acc, { [award.name]: [award] }); - } - - return acc; - }, {}); - - const orderedAwards = {}; - const { thumbsdown, thumbsup } = awards; - // Always show thumbsup and thumbsdown first - if (thumbsup) { - orderedAwards.thumbsup = thumbsup; - delete awards.thumbsup; - } - if (thumbsdown) { - orderedAwards.thumbsdown = thumbsdown; - delete awards.thumbsdown; - } - - return Object.assign({}, orderedAwards, awards); - }, isAuthoredByMe() { return this.noteAuthorId === this.getUserData.id; }, + addButtonClass() { + return this.isAuthoredByMe ? 'js-user-authored' : ''; + }, }, methods: { ...mapActions(['toggleAwardRequest']), - getAwardHTML(name) { - return glEmojiTag(name); - }, - getAwardClassBindings(awardList) { - return { - active: this.hasReactionByCurrentUser(awardList), - disabled: !this.canInteractWithEmoji(), - }; - }, - canInteractWithEmoji() { - return this.getUserData.id; - }, - hasReactionByCurrentUser(awardList) { - return awardList.filter(award => award.user.id === this.getUserData.id).length; - }, - awardTitle(awardsList) { - const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList); - const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10; - let awardList = awardsList; - - // Filter myself from list if I am awarded. - if (hasReactionByCurrentUser) { - awardList = awardList.filter(award => award.user.id !== this.getUserData.id); - } - - // Get only 9-10 usernames to show in tooltip text. - const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name); - - // Get the remaining list to use in `and x more` text. - const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length); - - // Add myself to the beginning of the list so title will start with You. - if (hasReactionByCurrentUser) { - namesToShow.unshift(__('You')); - } - - let title = ''; - - // We have 10+ awarded user, join them with comma and add `and x more`. - if (remainingAwardList.length) { - title = sprintf( - __(`%{listToShow}, and %{awardsListLength} more.`), - { - listToShow: namesToShow.join(', '), - awardsListLength: remainingAwardList.length, - }, - false, - ); - } else if (namesToShow.length > 1) { - // Join all names with comma but not the last one, it will be added with and text. - title = namesToShow.slice(0, namesToShow.length - 1).join(', '); - // If we have more than 2 users we need an extra comma before and text. - title += namesToShow.length > 2 ? ',' : ''; - title += sprintf(__(` and %{sliced}`), { sliced: namesToShow.slice(-1) }, false); // Append and text - } else { - // We have only 2 users so join them with and. - title = namesToShow.join(__(' and ')); - } - - return title; - }, handleAward(awardName) { - if (!this.canAwardEmoji) { - return; - } - - let parsedName; - - // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string - switch (awardName) { - case '100': - parsedName = 100; - break; - case '1234': - parsedName = 1234; - break; - default: - parsedName = awardName; - break; - } - const data = { endpoint: this.toggleAwardPath, noteId: this.noteId, - awardName: parsedName, + awardName, }; this.toggleAwardRequest(data).catch(() => Flash(__('Something went wrong on our end.'))); @@ -171,46 +56,12 @@ export default { <template> <div class="note-awards"> - <div class="awards js-awards-block"> - <button - v-for="(awardList, awardName, index) in groupedAwards" - :key="index" - v-tooltip - :class="getAwardClassBindings(awardList)" - :title="awardTitle(awardList)" - data-boundary="viewport" - class="btn award-control" - type="button" - @click="handleAward(awardName)" - > - <span v-html="getAwardHTML(awardName)"></span> - <span class="award-control-text js-counter">{{ awardList.length }}</span> - </button> - <div v-if="canAwardEmoji" class="award-menu-holder"> - <button - v-tooltip - :class="{ 'js-user-authored': isAuthoredByMe }" - class="award-control btn js-add-award" - title="Add reaction" - :aria-label="__('Add reaction')" - data-boundary="viewport" - type="button" - > - <span class="award-control-icon award-control-icon-neutral"> - <icon name="slight-smile" /> - </span> - <span class="award-control-icon award-control-icon-positive"> - <icon name="smiley" /> - </span> - <span class="award-control-icon award-control-icon-super-positive"> - <icon name="smiley" /> - </span> - <i - aria-hidden="true" - class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading" - ></i> - </button> - </div> - </div> + <awards-list + :awards="awards" + :can-award-emoji="canAwardEmoji" + :current-user-id="getUserData.id" + :add-button-class="addButtonClass" + @award="handleAward($event)" + /> </div> </template> diff --git a/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js b/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js index c40503603be..bbaaeb55c65 100644 --- a/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js +++ b/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js @@ -1,8 +1,3 @@ -import UsagePingPayload from './../usage_ping_payload'; +import setup from 'ee_else_ce/admin/application_settings/setup_metrics_and_profiling'; -document.addEventListener('DOMContentLoaded', () => { - new UsagePingPayload( - document.querySelector('.js-usage-ping-payload-trigger'), - document.querySelector('.js-usage-ping-payload'), - ).init(); -}); +document.addEventListener('DOMContentLoaded', setup); diff --git a/app/assets/javascripts/pages/admin/application_settings/usage_ping_payload.js b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js index 95f4ba28b42..413045d960e 100644 --- a/app/assets/javascripts/pages/admin/application_settings/usage_ping_payload.js +++ b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js @@ -2,7 +2,7 @@ import axios from '../../../lib/utils/axios_utils'; import { __ } from '../../../locale'; import flash from '../../../flash'; -export default class UsagePingPayload { +export default class PayloadPreviewer { constructor(trigger, container) { this.trigger = trigger; this.container = container; @@ -38,7 +38,7 @@ export default class UsagePingPayload { }) .catch(() => { this.spinner.classList.remove('d-inline-flex'); - flash(__('Error fetching usage ping data.')); + flash(__('Error fetching payload data.')); }); } diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index a3743ded601..6efddec1172 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -3,6 +3,7 @@ import { GlSprintf, GlLink } from '@gitlab/ui'; import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin'; import { s__ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import projectFeatureSetting from './project_feature_setting.vue'; import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue'; import projectSettingRow from './project_setting_row.vue'; @@ -24,7 +25,7 @@ export default { GlSprintf, GlLink, }, - mixins: [settingsMixin], + mixins: [settingsMixin, glFeatureFlagsMixin()], props: { currentSettings: { @@ -116,6 +117,8 @@ export default { const defaults = { visibilityOptions, visibilityLevel: visibilityOptions.PUBLIC, + // TODO: Change all of these to use the visibilityOptions constants + // https://gitlab.com/gitlab-org/gitlab/-/issues/214667 issuesAccessLevel: 20, repositoryAccessLevel: 20, forkingAccessLevel: 20, @@ -124,11 +127,14 @@ export default { wikiAccessLevel: 20, snippetsAccessLevel: 20, pagesAccessLevel: 20, + metricsAccessLevel: visibilityOptions.PRIVATE, containerRegistryEnabled: true, lfsEnabled: true, requestAccessEnabled: true, highlightChangesClass: false, emailsDisabled: false, + featureAccessLevelEveryone, + featureAccessLevelMembers, }; return { ...defaults, ...this.currentSettings }; @@ -189,6 +195,10 @@ export default { 'ProjectSettings|View and edit files in this project. Non-project members will only have read access', ); }, + + metricsDashboardVisibilitySwitchingAvailable() { + return this.glFeatures.metricsDashboardVisibilitySwitchingAvailable; + }, }, watch: { @@ -462,6 +472,38 @@ export default { name="project[project_feature_attributes][pages_access_level]" /> </project-setting-row> + <project-setting-row + v-if="metricsDashboardVisibilitySwitchingAvailable" + ref="metrics-visibility-settings" + :label="__('Metrics Dashboard')" + :help-text=" + s__( + 'ProjectSettings|With Metrics Dashboard you can visualize this project performance metrics', + ) + " + > + <div class="project-feature-controls"> + <div class="select-wrapper"> + <select + v-model="metricsAccessLevel" + name="project[project_feature_attributes][metrics_dashboard_access_level]" + class="form-control select-control" + > + <option + :value="visibilityOptions.PRIVATE" + :disabled="!visibilityAllowed(visibilityOptions.PRIVATE)" + >{{ featureAccessLevelMembers[1] }}</option + > + <option + :value="visibilityOptions.PUBLIC" + :disabled="!visibilityAllowed(visibilityOptions.PUBLIC)" + >{{ featureAccessLevelEveryone[1] }}</option + > + </select> + <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i> + </div> + </div> + </project-setting-row> </div> <project-setting-row v-if="canDisableEmails" ref="email-settings" class="mb-3"> <label class="js-emails-disabled"> diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js index e5898c3b047..2d321ead33e 100644 --- a/app/assets/javascripts/projects/default_project_templates.js +++ b/app/assets/javascripts/projects/default_project_templates.js @@ -53,6 +53,10 @@ export default { text: s__('ProjectTemplates|Pages/Hexo'), icon: '.template-option .icon-hexo', }, + sse_middleman: { + text: s__('ProjectTemplates|Static Site Editor/Middleman'), + icon: '.template-option .icon-sse_middleman', + }, nfhugo: { text: s__('ProjectTemplates|Netlify/Hugo'), icon: '.template-option .icon-nfhugo', diff --git a/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue b/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue index 6acf366e531..88a0710574f 100644 --- a/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue +++ b/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue @@ -53,7 +53,6 @@ export default { :primary-button-text="alertConfiguration.primaryButton" :primary-button-link="config.settingsPath" :title="alertConfiguration.title" - class="my-2" > <gl-sprintf :message="alertConfiguration.message"> <template #days> diff --git a/app/assets/javascripts/registry/explorer/constants.js b/app/assets/javascripts/registry/explorer/constants.js index 586231d19c7..d4b9d25b212 100644 --- a/app/assets/javascripts/registry/explorer/constants.js +++ b/app/assets/javascripts/registry/explorer/constants.js @@ -1,16 +1,44 @@ import { s__ } from '~/locale'; +// List page + +export const CONTAINER_REGISTRY_TITLE = s__('ContainerRegistry|Container Registry'); +export const CONNECTION_ERROR_TITLE = s__('ContainerRegistry|Docker connection error'); +export const CONNECTION_ERROR_MESSAGE = s__( + `ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}`, +); +export const LIST_INTRO_TEXT = s__( + `ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`, +); + +export const LIST_DELETE_BUTTON_DISABLED = s__( + 'ContainerRegistry|Missing or insufficient permission, delete button disabled', +); +export const REMOVE_REPOSITORY_LABEL = s__('ContainerRegistry|Remove repository'); +export const REMOVE_REPOSITORY_MODAL_TEXT = s__( + 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.', +); +export const ROW_SCHEDULED_FOR_DELETION = s__( + `ContainerRegistry|This image repository is scheduled for deletion`, +); export const FETCH_IMAGES_LIST_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while fetching the packages list.', + 'ContainerRegistry|Something went wrong while fetching the repository list.', ); export const FETCH_TAGS_LIST_ERROR_MESSAGE = s__( 'ContainerRegistry|Something went wrong while fetching the tags list.', ); - export const DELETE_IMAGE_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while deleting the image.', + 'ContainerRegistry|Something went wrong while scheduling %{title} for deletion. Please try again.', ); -export const DELETE_IMAGE_SUCCESS_MESSAGE = s__('ContainerRegistry|Image deleted successfully'); +export const ASYNC_DELETE_IMAGE_ERROR_MESSAGE = s__( + `ContainerRegistry|There was an error during the deletion of this image repository, please try again.`, +); +export const DELETE_IMAGE_SUCCESS_MESSAGE = s__( + 'ContainerRegistry|%{title} was successfully scheduled for deletion', +); + +// Image details page + export const DELETE_TAG_ERROR_MESSAGE = s__( 'ContainerRegistry|Something went wrong while deleting the tag.', ); @@ -37,6 +65,8 @@ export const LIST_LABEL_IMAGE_ID = s__('ContainerRegistry|Image ID'); export const LIST_LABEL_SIZE = s__('ContainerRegistry|Compressed Size'); export const LIST_LABEL_LAST_UPDATED = s__('ContainerRegistry|Last Updated'); +// Expiration policies + export const EXPIRATION_POLICY_ALERT_TITLE = s__( 'ContainerRegistry|Retention policy has been Enabled', ); @@ -48,6 +78,8 @@ export const EXPIRATION_POLICY_ALERT_SHORT_MESSAGE = s__( 'ContainerRegistry|The retention and expiration policy for this Container Registry has been enabled. For more information visit the %{linkStart}documentation%{linkEnd}', ); +// Quick Start + export const QUICK_START = s__('ContainerRegistry|Quick Start'); export const LOGIN_COMMAND_LABEL = s__('ContainerRegistry|Login'); export const COPY_LOGIN_TITLE = s__('ContainerRegistry|Copy login command'); @@ -55,3 +87,8 @@ export const BUILD_COMMAND_LABEL = s__('ContainerRegistry|Build an image'); export const COPY_BUILD_TITLE = s__('ContainerRegistry|Copy build command'); export const PUSH_COMMAND_LABEL = s__('ContainerRegistry|Push an image'); export const COPY_PUSH_TITLE = s__('ContainerRegistry|Copy push command'); + +// Image state + +export const IMAGE_DELETE_SCHEDULED_STATUS = 'delete_scheduled'; +export const IMAGE_FAILED_DELETED_STATUS = 'delete_failed'; diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue index 7204cbd90eb..8923c305b2d 100644 --- a/app/assets/javascripts/registry/explorer/pages/list.vue +++ b/app/assets/javascripts/registry/explorer/pages/list.vue @@ -9,16 +9,28 @@ import { GlModal, GlSprintf, GlLink, + GlAlert, GlSkeletonLoader, } from '@gitlab/ui'; import Tracking from '~/tracking'; -import { s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ProjectEmptyState from '../components/project_empty_state.vue'; import GroupEmptyState from '../components/group_empty_state.vue'; import ProjectPolicyAlert from '../components/project_policy_alert.vue'; import QuickstartDropdown from '../components/quickstart_dropdown.vue'; -import { DELETE_IMAGE_SUCCESS_MESSAGE, DELETE_IMAGE_ERROR_MESSAGE } from '../constants'; +import { + DELETE_IMAGE_SUCCESS_MESSAGE, + DELETE_IMAGE_ERROR_MESSAGE, + ASYNC_DELETE_IMAGE_ERROR_MESSAGE, + CONTAINER_REGISTRY_TITLE, + CONNECTION_ERROR_TITLE, + CONNECTION_ERROR_MESSAGE, + LIST_INTRO_TEXT, + LIST_DELETE_BUTTON_DISABLED, + REMOVE_REPOSITORY_LABEL, + REMOVE_REPOSITORY_MODAL_TEXT, + ROW_SCHEDULED_FOR_DELETION, +} from '../constants'; export default { name: 'RegistryListApp', @@ -35,6 +47,7 @@ export default { GlModal, GlSprintf, GlLink, + GlAlert, GlSkeletonLoader, }, directives: { @@ -47,25 +60,20 @@ export default { height: 40, }, i18n: { - containerRegistryTitle: s__('ContainerRegistry|Container Registry'), - connectionErrorTitle: s__('ContainerRegistry|Docker connection error'), - connectionErrorMessage: s__( - `ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}`, - ), - introText: s__( - `ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`, - ), - deleteButtonDisabled: s__( - 'ContainerRegistry|Missing or insufficient permission, delete button disabled', - ), - removeRepositoryLabel: s__('ContainerRegistry|Remove repository'), - removeRepositoryModalText: s__( - 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.', - ), + containerRegistryTitle: CONTAINER_REGISTRY_TITLE, + connectionErrorTitle: CONNECTION_ERROR_TITLE, + connectionErrorMessage: CONNECTION_ERROR_MESSAGE, + introText: LIST_INTRO_TEXT, + deleteButtonDisabled: LIST_DELETE_BUTTON_DISABLED, + removeRepositoryLabel: REMOVE_REPOSITORY_LABEL, + removeRepositoryModalText: REMOVE_REPOSITORY_MODAL_TEXT, + rowScheduledForDeletion: ROW_SCHEDULED_FOR_DELETION, + asyncDeleteErrorMessage: ASYNC_DELETE_IMAGE_ERROR_MESSAGE, }, data() { return { itemToDelete: {}, + deleteAlertType: null, }; }, computed: { @@ -86,43 +94,61 @@ export default { showQuickStartDropdown() { return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length); }, + showDeleteAlert() { + return this.deleteAlertType && this.itemToDelete?.path; + }, + deleteImageAlertMessage() { + return this.deleteAlertType === 'success' + ? DELETE_IMAGE_SUCCESS_MESSAGE + : DELETE_IMAGE_ERROR_MESSAGE; + }, }, methods: { ...mapActions(['requestImagesList', 'requestDeleteImage']), deleteImage(item) { - // This event is already tracked in the system and so the name must be kept to aggregate the data this.track('click_button'); this.itemToDelete = item; this.$refs.deleteModal.show(); }, handleDeleteImage() { this.track('confirm_delete'); - return this.requestDeleteImage(this.itemToDelete.destroy_path) - .then(() => - this.$toast.show(DELETE_IMAGE_SUCCESS_MESSAGE, { - type: 'success', - }), - ) - .catch(() => - this.$toast.show(DELETE_IMAGE_ERROR_MESSAGE, { - type: 'error', - }), - ) - .finally(() => { - this.itemToDelete = {}; + return this.requestDeleteImage(this.itemToDelete) + .then(() => { + this.deleteAlertType = 'success'; + }) + .catch(() => { + this.deleteAlertType = 'danger'; }); }, encodeListItem(item) { const params = JSON.stringify({ name: item.path, tags_path: item.tags_path, id: item.id }); return window.btoa(params); }, + dismissDeleteAlert() { + this.deleteAlertType = null; + this.itemToDelete = {}; + }, }, }; </script> <template> <div class="w-100 slide-enter-from-element"> - <project-policy-alert v-if="!config.isGroupPage" /> + <gl-alert + v-if="showDeleteAlert" + :variant="deleteAlertType" + class="mt-2" + dismissible + @dismiss="dismissDeleteAlert" + > + <gl-sprintf :message="deleteImageAlertMessage"> + <template #title> + {{ itemToDelete.path }} + </template> + </gl-sprintf> + </gl-alert> + + <project-policy-alert v-if="!config.isGroupPage" class="mt-2" /> <gl-empty-state v-if="config.characterError" @@ -178,41 +204,57 @@ export default { v-for="(listItem, index) in images" :key="index" ref="rowItem" - :class="{ 'border-top': index === 0 }" - class="d-flex justify-content-between align-items-center py-2 border-bottom" + v-gl-tooltip="{ + placement: 'left', + disabled: !listItem.deleting, + title: $options.i18n.rowScheduledForDeletion, + }" > - <div> - <router-link - ref="detailsLink" - :to="{ name: 'details', params: { id: encodeListItem(listItem) } }" - > - {{ listItem.path }} - </router-link> - <clipboard-button - v-if="listItem.location" - ref="clipboardButton" - :text="listItem.location" - :title="listItem.location" - css-class="btn-default btn-transparent btn-clipboard" - /> - </div> <div - v-gl-tooltip="{ disabled: listItem.destroy_path }" - class="d-none d-sm-block" - :title="$options.i18n.deleteButtonDisabled" + class="d-flex justify-content-between align-items-center py-2 px-1 border-bottom" + :class="{ 'border-top': index === 0, 'disabled-content': listItem.deleting }" > - <gl-deprecated-button - ref="deleteImageButton" - v-gl-tooltip - :disabled="!listItem.destroy_path" - :title="$options.i18n.removeRepositoryLabel" - :aria-label="$options.i18n.removeRepositoryLabel" - class="btn-inverted" - variant="danger" - @click="deleteImage(listItem)" + <div class="d-felx align-items-center"> + <router-link + ref="detailsLink" + :to="{ name: 'details', params: { id: encodeListItem(listItem) } }" + > + {{ listItem.path }} + </router-link> + <clipboard-button + v-if="listItem.location" + ref="clipboardButton" + :disabled="listItem.deleting" + :text="listItem.location" + :title="listItem.location" + css-class="btn-default btn-transparent btn-clipboard" + /> + <gl-icon + v-if="listItem.failedDelete" + v-gl-tooltip + :title="$options.i18n.asyncDeleteErrorMessage" + name="warning" + class="text-warning align-middle" + /> + </div> + <div + v-gl-tooltip="{ disabled: listItem.destroy_path }" + class="d-none d-sm-block" + :title="$options.i18n.deleteButtonDisabled" > - <gl-icon name="remove" /> - </gl-deprecated-button> + <gl-deprecated-button + ref="deleteImageButton" + v-gl-tooltip + :disabled="!listItem.destroy_path || listItem.deleting" + :title="$options.i18n.removeRepositoryLabel" + :aria-label="$options.i18n.removeRepositoryLabel" + class="btn-inverted" + variant="danger" + @click="deleteImage(listItem)" + > + <gl-icon name="remove" /> + </gl-deprecated-button> + </div> </div> </div> <gl-pagination diff --git a/app/assets/javascripts/registry/explorer/stores/actions.js b/app/assets/javascripts/registry/explorer/stores/actions.js index 2abd72cb9a8..b4f66dbbcd6 100644 --- a/app/assets/javascripts/registry/explorer/stores/actions.js +++ b/app/assets/javascripts/registry/explorer/stores/actions.js @@ -88,14 +88,12 @@ export const requestDeleteTags = ({ commit, dispatch, state }, { ids, params }) }); }; -export const requestDeleteImage = ({ commit, dispatch, state }, destroyPath) => { +export const requestDeleteImage = ({ commit }, image) => { commit(types.SET_MAIN_LOADING, true); - return axios - .delete(destroyPath) + .delete(image.destroy_path) .then(() => { - dispatch('setShowGarbageCollectionTip', true); - dispatch('requestImagesList', { pagination: state.pagination }); + commit(types.UPDATE_IMAGE, { ...image, deleting: true }); }) .finally(() => { commit(types.SET_MAIN_LOADING, false); diff --git a/app/assets/javascripts/registry/explorer/stores/mutation_types.js b/app/assets/javascripts/registry/explorer/stores/mutation_types.js index 86eaa0dd2f1..f32cdf90783 100644 --- a/app/assets/javascripts/registry/explorer/stores/mutation_types.js +++ b/app/assets/javascripts/registry/explorer/stores/mutation_types.js @@ -1,6 +1,7 @@ export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; export const SET_IMAGES_LIST_SUCCESS = 'SET_PACKAGE_LIST_SUCCESS'; +export const UPDATE_IMAGE = 'UPDATE_IMAGE'; export const SET_PAGINATION = 'SET_PAGINATION'; export const SET_MAIN_LOADING = 'SET_MAIN_LOADING'; export const SET_TAGS_PAGINATION = 'SET_TAGS_PAGINATION'; diff --git a/app/assets/javascripts/registry/explorer/stores/mutations.js b/app/assets/javascripts/registry/explorer/stores/mutations.js index fda788051c0..b25a0221dc1 100644 --- a/app/assets/javascripts/registry/explorer/stores/mutations.js +++ b/app/assets/javascripts/registry/explorer/stores/mutations.js @@ -1,5 +1,6 @@ import * as types from './mutation_types'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import { IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS } from '../constants'; export default { [types.SET_INITIAL_STATE](state, config) { @@ -12,7 +13,17 @@ export default { }, [types.SET_IMAGES_LIST_SUCCESS](state, images) { - state.images = images; + state.images = images.map(i => ({ + ...i, + status: undefined, + deleting: i.status === IMAGE_DELETE_SCHEDULED_STATUS, + failedDelete: i.status === IMAGE_FAILED_DELETED_STATUS, + })); + }, + + [types.UPDATE_IMAGE](state, image) { + const index = state.images.findIndex(i => i.id === image.id); + state.images.splice(index, 1, { ...image }); }, [types.SET_TAGS_LIST_SUCCESS](state, tags) { diff --git a/app/assets/javascripts/releases/components/app_edit.vue b/app/assets/javascripts/releases/components/app_edit.vue index df356c18417..8d68ff02116 100644 --- a/app/assets/javascripts/releases/components/app_edit.vue +++ b/app/assets/javascripts/releases/components/app_edit.vue @@ -1,6 +1,6 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; -import { GlNewButton, GlFormInput, GlFormGroup } from '@gitlab/ui'; +import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui'; import { escape as esc } from 'lodash'; import { __, sprintf } from '~/locale'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; @@ -15,7 +15,7 @@ export default { components: { GlFormInput, GlFormGroup, - GlNewButton, + GlButton, MarkdownField, AssetLinksForm, }, @@ -167,7 +167,7 @@ export default { <asset-links-form v-if="showAssetLinksForm" /> <div class="d-flex pt-3"> - <gl-new-button + <gl-button class="mr-auto js-no-auto-disable" category="primary" variant="success" @@ -176,10 +176,10 @@ export default { :disabled="isSaveChangesDisabled" > {{ __('Save changes') }} - </gl-new-button> - <gl-new-button :href="cancelPath" class="js-cancel-button"> + </gl-button> + <gl-button :href="cancelPath" class="js-cancel-button"> {{ __('Cancel') }} - </gl-new-button> + </gl-button> </div> </form> </div> diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue index 6ca700c2b30..4bdc88f01dd 100644 --- a/app/assets/javascripts/releases/components/asset_links_form.vue +++ b/app/assets/javascripts/releases/components/asset_links_form.vue @@ -4,7 +4,7 @@ import { GlSprintf, GlLink, GlFormGroup, - GlNewButton, + GlButton, GlIcon, GlTooltipDirective, GlFormInput, @@ -12,7 +12,7 @@ import { export default { name: 'AssetLinksForm', - components: { GlSprintf, GlLink, GlFormGroup, GlNewButton, GlIcon, GlFormInput }, + components: { GlSprintf, GlLink, GlFormGroup, GlButton, GlIcon, GlFormInput }, directives: { GlTooltip: GlTooltipDirective }, computed: { ...mapState('detail', ['release', 'releaseAssetsDocsPath']), @@ -170,7 +170,7 @@ export default { </gl-form-group> <div class="mb-5 mb-sm-3 mt-sm-4 col col-sm-auto"> - <gl-new-button + <gl-button v-gl-tooltip class="remove-button w-100" :aria-label="__('Remove asset link')" @@ -179,16 +179,16 @@ export default { > <gl-icon class="mr-1 mr-sm-0 mb-1" :size="16" name="remove" /> <span class="d-inline d-sm-none">{{ __('Remove asset link') }}</span> - </gl-new-button> + </gl-button> </div> </div> - <gl-new-button + <gl-button ref="addAnotherLinkButton" variant="link" class="align-self-end mb-5 mb-sm-0" @click="onAddAnotherClicked" > {{ __('Add another link') }} - </gl-new-button> + </gl-button> </div> </template> diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index 6c58f48dc74..fdd6b4eb87a 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -108,14 +108,14 @@ export default { return acc.concat({ name, path, - to: `/-/tree/${joinPaths(escape(this.ref), path)}`, + to: `/-/tree/${joinPaths(encodeURIComponent(this.ref), path)}`, }); }, [ { name: this.projectShortPath, path: '/', - to: `/-/tree/${escape(this.ref)}/`, + to: `/-/tree/${encodeURIComponent(this.ref)}/`, }, ], ); diff --git a/app/assets/javascripts/repository/components/table/parent_row.vue b/app/assets/javascripts/repository/components/table/parent_row.vue index f9fcbc356e8..32bdda2e0a8 100644 --- a/app/assets/javascripts/repository/components/table/parent_row.vue +++ b/app/assets/javascripts/repository/components/table/parent_row.vue @@ -28,7 +28,7 @@ export default { return splitArray.map(p => encodeURIComponent(p)).join('/'); }, parentRoute() { - return { path: `/-/tree/${escape(this.commitRef)}/${this.parentPath}` }; + return { path: `/-/tree/${encodeURIComponent(this.commitRef)}/${this.parentPath}` }; }, }, methods: { diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 00ccc49d770..d9ef6eec6f1 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -99,7 +99,7 @@ export default { computed: { routerLinkTo() { return this.isFolder - ? { path: `/-/tree/${escape(this.ref)}/${escapeFileUrl(this.path)}` } + ? { path: `/-/tree/${encodeURIComponent(this.ref)}/${escapeFileUrl(this.path)}` } : null; }, isFolder() { diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js index 0c68b5a599b..6640b636597 100644 --- a/app/assets/javascripts/repository/graphql.js +++ b/app/assets/javascripts/repository/graphql.js @@ -48,7 +48,7 @@ const defaultClient = createDefaultClient( case 'TreeEntry': case 'Submodule': case 'Blob': - return `${escape(obj.flatPath)}-${obj.id}`; + return `${encodeURIComponent(obj.flatPath)}-${obj.id}`; default: // If the type doesn't match any of the above we fallback // to using the default Apollo ID diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 637060f6ed9..05783fc3b5d 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -100,7 +100,9 @@ export default function setupVueRepositoryList() { render(h) { return h(TreeActionLink, { props: { - path: `${historyLink}/${this.$route.params.path ? escape(this.$route.params.path) : ''}`, + path: `${historyLink}/${ + this.$route.params.path ? encodeURIComponent(this.$route.params.path) : '' + }`, text: __('History'), }, }); diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js index b2636f910fe..d74447dd566 100644 --- a/app/assets/javascripts/repository/router.js +++ b/app/assets/javascripts/repository/router.js @@ -12,7 +12,7 @@ export default function createRouter(base, baseRef) { base: joinPaths(gon.relative_url_root || '', base), routes: [ { - path: `(/-)?/tree/(${encodeURIComponent(baseRef).replace(/%2F/g, '/')}|${baseRef})/:path*`, + path: `(/-)?/tree/(${encodeURIComponent(baseRef)}|${baseRef})/:path*`, name: 'treePath', component: TreePage, props: route => ({ diff --git a/app/assets/javascripts/snippet/snippet_edit.js b/app/assets/javascripts/snippet/snippet_edit.js index a098d17a226..b0d373b1a4b 100644 --- a/app/assets/javascripts/snippet/snippet_edit.js +++ b/app/assets/javascripts/snippet/snippet_edit.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import initSnippet from '~/snippet/snippet_bundle'; import ZenMode from '~/zen_mode'; import GLForm from '~/gl_form'; +import { SnippetEditInit } from '~/snippets'; document.addEventListener('DOMContentLoaded', () => { const form = document.querySelector('.snippet-form'); @@ -17,9 +18,15 @@ document.addEventListener('DOMContentLoaded', () => { const projectSnippetOptions = {}; const options = - form.dataset.snippetType === 'project' ? projectSnippetOptions : personalSnippetOptions; + form.dataset.snippetType === 'project' || form.dataset.projectPath + ? projectSnippetOptions + : personalSnippetOptions; - initSnippet(); + if (gon?.features?.snippetsEditVue) { + SnippetEditInit(); + } else { + initSnippet(); + new GLForm($(form), options); // eslint-disable-line no-new + } new ZenMode(); // eslint-disable-line no-new - new GLForm($(form), options); // eslint-disable-line no-new }); diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue new file mode 100644 index 00000000000..2185b1d67e4 --- /dev/null +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -0,0 +1,211 @@ +<script> +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; + +import Flash from '~/flash'; +import { __, sprintf } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; +import TitleField from '~/vue_shared/components/form/title.vue'; +import { getBaseURL, joinPaths, redirectTo } from '~/lib/utils/url_utility'; +import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue'; + +import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql'; +import CreateSnippetMutation from '../mutations/createSnippet.mutation.graphql'; +import { getSnippetMixin } from '../mixins/snippets'; +import { SNIPPET_VISIBILITY_PRIVATE } from '../constants'; +import SnippetBlobEdit from './snippet_blob_edit.vue'; +import SnippetVisibilityEdit from './snippet_visibility_edit.vue'; +import SnippetDescriptionEdit from './snippet_description_edit.vue'; + +export default { + components: { + SnippetDescriptionEdit, + SnippetVisibilityEdit, + SnippetBlobEdit, + TitleField, + FormFooterActions, + GlButton, + GlLoadingIcon, + }, + mixins: [getSnippetMixin], + props: { + markdownPreviewPath: { + type: String, + required: true, + }, + markdownDocsPath: { + type: String, + required: true, + }, + visibilityHelpLink: { + type: String, + default: '', + required: false, + }, + projectPath: { + type: String, + default: '', + required: false, + }, + }, + data() { + return { + blob: {}, + fileName: '', + content: '', + isContentLoading: true, + isUpdating: false, + newSnippet: false, + }; + }, + computed: { + updatePrevented() { + return this.snippet.title === '' || this.content === '' || this.isUpdating; + }, + isProjectSnippet() { + return Boolean(this.projectPath); + }, + apiData() { + return { + id: this.snippet.id, + title: this.snippet.title, + description: this.snippet.description, + visibilityLevel: this.snippet.visibilityLevel, + fileName: this.fileName, + content: this.content, + }; + }, + saveButtonLabel() { + if (this.newSnippet) { + return __('Create snippet'); + } + return this.isUpdating ? __('Saving') : __('Save changes'); + }, + cancelButtonHref() { + return this.projectPath ? `/${this.projectPath}/snippets` : `/snippets`; + }, + titleFieldId() { + return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_title`; + }, + descriptionFieldId() { + return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_description`; + }, + }, + methods: { + updateFileName(newName) { + this.fileName = newName; + }, + flashAPIFailure(err) { + Flash(sprintf(__("Can't update snippet: %{err}"), { err })); + }, + onNewSnippetFetched() { + this.newSnippet = true; + this.snippet = this.$options.newSnippetSchema; + this.blob = this.snippet.blob; + this.isContentLoading = false; + }, + onExistingSnippetFetched() { + this.newSnippet = false; + const { blob } = this.snippet; + this.blob = blob; + this.fileName = blob.name; + const baseUrl = getBaseURL(); + const url = joinPaths(baseUrl, blob.rawPath); + + axios + .get(url) + .then(res => { + this.content = res.data; + this.isContentLoading = false; + }) + .catch(e => this.flashAPIFailure(e)); + }, + onSnippetFetch(snippetRes) { + if (snippetRes.data.snippets.edges.length === 0) { + this.onNewSnippetFetched(); + } else { + this.onExistingSnippetFetched(); + } + }, + handleFormSubmit() { + this.isUpdating = true; + this.$apollo + .mutate({ + mutation: this.newSnippet ? CreateSnippetMutation : UpdateSnippetMutation, + variables: { + input: { + ...this.apiData, + projectPath: this.newSnippet ? this.projectPath : undefined, + }, + }, + }) + .then(({ data }) => { + const baseObj = this.newSnippet ? data?.createSnippet : data?.updateSnippet; + + const errors = baseObj?.errors; + if (errors.length) { + this.flashAPIFailure(errors[0]); + } + redirectTo(baseObj.snippet.webUrl); + }) + .catch(e => { + this.isUpdating = false; + this.flashAPIFailure(e); + }); + }, + }, + newSnippetSchema: { + title: '', + description: '', + visibilityLevel: SNIPPET_VISIBILITY_PRIVATE, + blob: {}, + }, +}; +</script> +<template> + <form + class="snippet-form js-requires-input js-quick-submit common-note-form" + :data-snippet-type="isProjectSnippet ? 'project' : 'personal'" + > + <gl-loading-icon + v-if="isLoading" + :label="__('Loading snippet')" + size="lg" + class="loading-animation prepend-top-20 append-bottom-20" + /> + <template v-else> + <title-field :id="titleFieldId" v-model="snippet.title" required :autofocus="true" /> + <snippet-description-edit + :id="descriptionFieldId" + v-model="snippet.description" + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath" + /> + <snippet-blob-edit + v-model="content" + :file-name="fileName" + :is-loading="isContentLoading" + @name-change="updateFileName" + /> + <snippet-visibility-edit + v-model="snippet.visibilityLevel" + :help-link="visibilityHelpLink" + :is-project-snippet="isProjectSnippet" + /> + <form-footer-actions> + <template #prepend> + <gl-button + type="submit" + category="primary" + variant="success" + :disabled="updatePrevented" + @click="handleFormSubmit" + >{{ saveButtonLabel }}</gl-button + > + </template> + <template #append> + <gl-button :href="cancelButtonHref">{{ __('Cancel') }}</gl-button> + </template> + </form-footer-actions> + </template> + </form> +</template> diff --git a/app/assets/javascripts/snippets/components/snippet_description_edit.vue b/app/assets/javascripts/snippets/components/snippet_description_edit.vue index 68810f8ab3f..6f3a86be8d7 100644 --- a/app/assets/javascripts/snippets/components/snippet_description_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_description_edit.vue @@ -50,7 +50,6 @@ export default { :markdown-docs-path="markdownDocsPath" > <textarea - id="snippet-description" slot="textarea" class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea" @@ -59,6 +58,7 @@ export default { :value="value" :aria-label="__('Description')" :placeholder="__('Write a comment or drag your files here…')" + v-bind="$attrs" @input="$emit('input', $event.target.value)" > </textarea> diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue index 79b191cb25a..30a23b51bc4 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -9,7 +9,7 @@ import { GlLoadingIcon, GlDropdown, GlDropdownItem, - GlNewButton, + GlButton, } from '@gitlab/ui'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -28,7 +28,7 @@ export default { GlDropdown, GlDropdownItem, TimeAgoTooltip, - GlNewButton, + GlButton, }, apollo: { canCreateSnippet: { @@ -186,7 +186,7 @@ export default { <div class="detail-page-header-actions"> <div class="d-none d-sm-flex"> <template v-for="(action, index) in personalSnippetActions"> - <gl-new-button + <gl-button v-if="action.condition" :key="index" :disabled="action.disabled" @@ -197,7 +197,7 @@ export default { @click="action.click ? action.click() : undefined" > {{ action.text }} - </gl-new-button> + </gl-button> </template> </div> <div class="d-block d-sm-none dropdown"> @@ -227,8 +227,8 @@ export default { </gl-sprintf> <template #modal-footer> - <gl-new-button @click="closeDeleteModal">{{ __('Cancel') }}</gl-new-button> - <gl-new-button + <gl-button @click="closeDeleteModal">{{ __('Cancel') }}</gl-button> + <gl-button variant="danger" category="primary" :disabled="isDeleting" @@ -237,7 +237,7 @@ export default { > <gl-loading-icon v-if="isDeleting" inline /> {{ __('Delete snippet') }} - </gl-new-button> + </gl-button> </template> </gl-modal> </div> diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js index b826110117c..1c79492957d 100644 --- a/app/assets/javascripts/snippets/index.js +++ b/app/assets/javascripts/snippets/index.js @@ -3,7 +3,8 @@ import Translate from '~/vue_shared/translate'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import SnippetsApp from './components/show.vue'; +import SnippetsShow from './components/show.vue'; +import SnippetsEdit from './components/edit.vue'; Vue.use(VueApollo); Vue.use(Translate); @@ -31,7 +32,11 @@ function appFactory(el, Component) { } export const SnippetShowInit = () => { - appFactory(document.getElementById('js-snippet-view'), SnippetsApp); + appFactory(document.getElementById('js-snippet-view'), SnippetsShow); +}; + +export const SnippetEditInit = () => { + appFactory(document.getElementById('js-snippet-edit'), SnippetsEdit); }; export default () => {}; diff --git a/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql new file mode 100644 index 00000000000..f688868d1b9 --- /dev/null +++ b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql @@ -0,0 +1,8 @@ +mutation CreateSnippet($input: CreateSnippetInput!) { + createSnippet(input: $input) { + errors + snippet { + webUrl + } + } +} diff --git a/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql new file mode 100644 index 00000000000..548725f7357 --- /dev/null +++ b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql @@ -0,0 +1,8 @@ +mutation UpdateSnippet($input: UpdateSnippetInput!) { + updateSnippet(input: $input) { + errors + snippet { + webUrl + } + } +} diff --git a/app/assets/javascripts/static_site_editor/components/invalid_content_message.vue b/app/assets/javascripts/static_site_editor/components/invalid_content_message.vue new file mode 100644 index 00000000000..fef87057307 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/components/invalid_content_message.vue @@ -0,0 +1,29 @@ +<script> +import { GlButton } from '@gitlab/ui'; + +export default { + components: { + GlButton, + }, +}; +</script> + +<template> + <div> + <h3>{{ s__('StaticSiteEditor|Incompatible file content') }}</h3> + <p> + {{ + s__( + 'StaticSiteEditor|The Static Site Editor is currently configured to only edit Markdown content on pages generated from Middleman. Visit the documentation to learn more about configuring your site to use the Static Site Editor.', + ) + }} + </p> + <div> + <gl-button + ref="documentationButton" + href="https://gitlab.com/gitlab-org/project-templates/static-site-editor-middleman" + >{{ s__('StaticSiteEditor|View documentation') }}</gl-button + > + </div> + </div> +</template> diff --git a/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue b/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue index efb442d4d09..274d2f71749 100644 --- a/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue +++ b/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue @@ -1,9 +1,9 @@ <script> -import { GlNewButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; export default { components: { - GlNewButton, + GlButton, GlLoadingIcon, }, props: { @@ -29,16 +29,12 @@ export default { <div class="d-flex bg-light border-top justify-content-between align-items-center py-3 px-4"> <gl-loading-icon :class="{ invisible: !savingChanges }" size="md" /> <div> - <gl-new-button v-if="returnUrl" ref="returnUrlLink" :href="returnUrl">{{ + <gl-button v-if="returnUrl" ref="returnUrlLink" :href="returnUrl">{{ s__('StaticSiteEditor|Return to site') - }}</gl-new-button> - <gl-new-button - variant="success" - :disabled="!saveable || savingChanges" - @click="$emit('submit')" - > + }}</gl-button> + <gl-button variant="success" :disabled="!saveable || savingChanges" @click="$emit('submit')"> {{ __('Submit Changes') }} - </gl-new-button> + </gl-button> </div> </div> </template> diff --git a/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue b/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue index d76c6d9d681..41cb901720c 100644 --- a/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue +++ b/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue @@ -1,14 +1,14 @@ <script> import { isString } from 'lodash'; -import { GlLink, GlNewButton } from '@gitlab/ui'; +import { GlLink, GlButton } from '@gitlab/ui'; const validateUrlAndLabel = value => isString(value.label) && isString(value.url); export default { components: { GlLink, - GlNewButton, + GlButton, }, props: { branch: { @@ -46,16 +46,12 @@ export default { }} </p> <div class="d-flex justify-content-end"> - <gl-new-button ref="returnToSiteButton" :href="returnUrl">{{ + <gl-button ref="returnToSiteButton" :href="returnUrl">{{ s__('StaticSiteEditor|Return to site') - }}</gl-new-button> - <gl-new-button - ref="mergeRequestButton" - class="ml-2" - :href="mergeRequest.url" - variant="success" - >{{ s__('StaticSiteEditor|View merge request') }}</gl-new-button - > + }}</gl-button> + <gl-button ref="mergeRequestButton" class="ml-2" :href="mergeRequest.url" variant="success"> + {{ s__('StaticSiteEditor|View merge request') }} + </gl-button> </div> </div> @@ -64,7 +60,7 @@ export default { <ul> <li> {{ s__('StaticSiteEditor|You created a new branch:') }} - <gl-link ref="branchLink" :href="branch.url">{{ branch.label }}</gl-link> + <span ref="branchLink">{{ branch.label }}</span> </li> <li> {{ s__('StaticSiteEditor|You created a merge request:') }} diff --git a/app/assets/javascripts/static_site_editor/components/static_site_editor.vue b/app/assets/javascripts/static_site_editor/components/static_site_editor.vue index 4d912f5c0b5..82917319fc3 100644 --- a/app/assets/javascripts/static_site_editor/components/static_site_editor.vue +++ b/app/assets/javascripts/static_site_editor/components/static_site_editor.vue @@ -4,14 +4,20 @@ import { GlSkeletonLoader } from '@gitlab/ui'; import EditArea from './edit_area.vue'; import EditHeader from './edit_header.vue'; +import SavedChangesMessage from './saved_changes_message.vue'; import Toolbar from './publish_toolbar.vue'; +import InvalidContentMessage from './invalid_content_message.vue'; +import SubmitChangesError from './submit_changes_error.vue'; export default { components: { EditArea, EditHeader, + InvalidContentMessage, GlSkeletonLoader, + SavedChangesMessage, Toolbar, + SubmitChangesError, }, computed: { ...mapState([ @@ -19,44 +25,71 @@ export default { 'isLoadingContent', 'isSavingChanges', 'isContentLoaded', + 'isSupportedContent', 'returnUrl', 'title', + 'submitChangesError', + 'savedContentMeta', ]), ...mapGetters(['contentChanged']), }, mounted() { - this.loadContent(); + if (this.isSupportedContent) { + this.loadContent(); + } }, methods: { - ...mapActions(['loadContent', 'setContent', 'submitChanges']), + ...mapActions(['loadContent', 'setContent', 'submitChanges', 'dismissSubmitChangesError']), }, }; </script> <template> <div class="d-flex justify-content-center h-100 pt-2"> - <div v-if="isLoadingContent" class="w-50 h-50"> - <gl-skeleton-loader :width="500" :height="102"> - <rect width="500" height="16" rx="4" /> - <rect y="20" width="375" height="16" rx="4" /> - <rect x="380" y="20" width="120" height="16" rx="4" /> - <rect y="40" width="250" height="16" rx="4" /> - <rect x="255" y="40" width="150" height="16" rx="4" /> - <rect x="410" y="40" width="90" height="16" rx="4" /> - </gl-skeleton-loader> - </div> - <div v-if="isContentLoaded" class="d-flex flex-grow-1 flex-column"> - <edit-header class="w-75 align-self-center py-2" :title="title" /> - <edit-area - class="w-75 h-100 shadow-none align-self-center" - :value="content" - @input="setContent" - /> - <toolbar - :return-url="returnUrl" - :saveable="contentChanged" - :saving-changes="isSavingChanges" - @submit="submitChanges" - /> - </div> + <!-- Success view --> + <saved-changes-message + v-if="savedContentMeta" + :branch="savedContentMeta.branch" + :commit="savedContentMeta.commit" + :merge-request="savedContentMeta.mergeRequest" + :return-url="returnUrl" + /> + + <!-- Main view --> + <template v-else-if="isSupportedContent"> + <div v-if="isLoadingContent" class="w-50 h-50"> + <gl-skeleton-loader :width="500" :height="102"> + <rect width="500" height="16" rx="4" /> + <rect y="20" width="375" height="16" rx="4" /> + <rect x="380" y="20" width="120" height="16" rx="4" /> + <rect y="40" width="250" height="16" rx="4" /> + <rect x="255" y="40" width="150" height="16" rx="4" /> + <rect x="410" y="40" width="90" height="16" rx="4" /> + </gl-skeleton-loader> + </div> + <div v-if="isContentLoaded" class="d-flex flex-grow-1 flex-column"> + <submit-changes-error + v-if="submitChangesError" + class="w-75 align-self-center" + :error="submitChangesError" + @retry="submitChanges" + @dismiss="dismissSubmitChangesError" + /> + <edit-header class="w-75 align-self-center py-2" :title="title" /> + <edit-area + class="w-75 h-100 shadow-none align-self-center" + :value="content" + @input="setContent" + /> + <toolbar + :return-url="returnUrl" + :saveable="contentChanged" + :saving-changes="isSavingChanges" + @submit="submitChanges" + /> + </div> + </template> + + <!-- Error view --> + <invalid-content-message v-else class="w-75" /> </div> </template> diff --git a/app/assets/javascripts/static_site_editor/components/submit_changes_error.vue b/app/assets/javascripts/static_site_editor/components/submit_changes_error.vue new file mode 100644 index 00000000000..c5b6c685124 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/components/submit_changes_error.vue @@ -0,0 +1,24 @@ +<script> +import { GlAlert, GlButton } from '@gitlab/ui'; + +export default { + components: { + GlAlert, + GlButton, + }, + props: { + error: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <gl-alert variant="danger" dismissible @dismiss="$emit('dismiss')"> + {{ s__('StaticSiteEditor|An error occurred while submitting your changes.') }} {{ error }} + <template #actions> + <gl-button variant="danger" @click="$emit('retry')">{{ __('Retry') }}</gl-button> + </template> + </gl-alert> +</template> diff --git a/app/assets/javascripts/static_site_editor/index.js b/app/assets/javascripts/static_site_editor/index.js index c6a883c659a..15d668fd431 100644 --- a/app/assets/javascripts/static_site_editor/index.js +++ b/app/assets/javascripts/static_site_editor/index.js @@ -3,10 +3,17 @@ import StaticSiteEditor from './components/static_site_editor.vue'; import createStore from './store'; const initStaticSiteEditor = el => { - const { projectId, returnUrl, path: sourcePath } = el.dataset; + const { projectId, path: sourcePath, returnUrl } = el.dataset; + const isSupportedContent = 'isSupportedContent' in el.dataset; const store = createStore({ - initialState: { projectId, returnUrl, sourcePath, username: window.gon.current_username }, + initialState: { + isSupportedContent, + projectId, + returnUrl, + sourcePath, + username: window.gon.current_username, + }, }); return new Vue({ diff --git a/app/assets/javascripts/static_site_editor/store/actions.js b/app/assets/javascripts/static_site_editor/store/actions.js index c57ef86f6ef..9f5e9e8c589 100644 --- a/app/assets/javascripts/static_site_editor/store/actions.js +++ b/app/assets/javascripts/static_site_editor/store/actions.js @@ -26,9 +26,12 @@ export const submitChanges = ({ state: { projectId, content, sourcePath, usernam return submitContentChanges({ content, projectId, sourcePath, username }) .then(data => commit(mutationTypes.SUBMIT_CHANGES_SUCCESS, data)) .catch(error => { - commit(mutationTypes.SUBMIT_CHANGES_ERROR); - createFlash(error.message); + commit(mutationTypes.SUBMIT_CHANGES_ERROR, error.message); }); }; +export const dismissSubmitChangesError = ({ commit }) => { + commit(mutationTypes.DISMISS_SUBMIT_CHANGES_ERROR); +}; + export default () => {}; diff --git a/app/assets/javascripts/static_site_editor/store/mutation_types.js b/app/assets/javascripts/static_site_editor/store/mutation_types.js index 35eb35ebbe9..9cf356aecc5 100644 --- a/app/assets/javascripts/static_site_editor/store/mutation_types.js +++ b/app/assets/javascripts/static_site_editor/store/mutation_types.js @@ -5,3 +5,4 @@ export const SET_CONTENT = 'setContent'; export const SUBMIT_CHANGES = 'submitChanges'; export const SUBMIT_CHANGES_SUCCESS = 'submitChangesSuccess'; export const SUBMIT_CHANGES_ERROR = 'submitChangesError'; +export const DISMISS_SUBMIT_CHANGES_ERROR = 'dismissSubmitChangesError'; diff --git a/app/assets/javascripts/static_site_editor/store/mutations.js b/app/assets/javascripts/static_site_editor/store/mutations.js index 4727d04439c..72fe71f1c9b 100644 --- a/app/assets/javascripts/static_site_editor/store/mutations.js +++ b/app/assets/javascripts/static_site_editor/store/mutations.js @@ -19,13 +19,18 @@ export default { }, [types.SUBMIT_CHANGES](state) { state.isSavingChanges = true; + state.submitChangesError = ''; }, [types.SUBMIT_CHANGES_SUCCESS](state, meta) { state.savedContentMeta = meta; state.isSavingChanges = false; state.originalContent = state.content; }, - [types.SUBMIT_CHANGES_ERROR](state) { + [types.SUBMIT_CHANGES_ERROR](state, error) { + state.submitChangesError = error; state.isSavingChanges = false; }, + [types.DISMISS_SUBMIT_CHANGES_ERROR](state) { + state.submitChangesError = ''; + }, }; diff --git a/app/assets/javascripts/static_site_editor/store/state.js b/app/assets/javascripts/static_site_editor/store/state.js index 98a84d9f75d..8c524b4ffe9 100644 --- a/app/assets/javascripts/static_site_editor/store/state.js +++ b/app/assets/javascripts/static_site_editor/store/state.js @@ -6,6 +6,7 @@ const createState = (initialState = {}) => ({ isLoadingContent: false, isSavingChanges: false, + isSupportedContent: false, isContentLoaded: false, @@ -13,6 +14,7 @@ const createState = (initialState = {}) => ({ content: '', title: '', + submitChangesError: '', savedContentMeta: null, ...initialState, diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index f8c1c3634c2..bde00d72620 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -38,8 +38,7 @@ const populateUserInfo = user => { name: userData.name, location: userData.location, bio: userData.bio, - organization: userData.organization, - jobTitle: userData.job_title, + workInformation: userData.work_information, loaded: true, }); } @@ -71,7 +70,7 @@ export default (elements = document.querySelectorAll('.js-user-link')) => { const user = { location: null, bio: null, - organization: null, + workInformation: null, status: null, loaded: false, }; diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue new file mode 100644 index 00000000000..848295cc984 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -0,0 +1,178 @@ +<script> +import { groupBy } from 'lodash'; +import { GlIcon } from '@gitlab/ui'; +import tooltip from '~/vue_shared/directives/tooltip'; +import { glEmojiTag } from '../../emoji'; +import { __, sprintf } from '~/locale'; + +// Internal constant, specific to this component, used when no `currentUserId` is given +const NO_USER_ID = -1; + +export default { + components: { + GlIcon, + }, + directives: { + tooltip, + }, + props: { + awards: { + type: Array, + required: true, + }, + canAwardEmoji: { + type: Boolean, + required: true, + }, + currentUserId: { + type: Number, + required: false, + default: NO_USER_ID, + }, + addButtonClass: { + type: String, + required: false, + default: '', + }, + }, + computed: { + groupedAwards() { + const { thumbsup, thumbsdown, ...rest } = groupBy(this.awards, x => x.name); + + return [ + ...(thumbsup ? [this.createAwardList('thumbsup', thumbsup)] : []), + ...(thumbsdown ? [this.createAwardList('thumbsdown', thumbsdown)] : []), + ...Object.entries(rest).map(([name, list]) => this.createAwardList(name, list)), + ]; + }, + isAuthoredByMe() { + return this.noteAuthorId === this.currentUserId; + }, + }, + methods: { + getAwardClassBindings(awardList) { + return { + active: this.hasReactionByCurrentUser(awardList), + disabled: this.currentUserId === NO_USER_ID, + }; + }, + hasReactionByCurrentUser(awardList) { + if (this.currentUserId === NO_USER_ID) { + return false; + } + + return awardList.some(award => award.user.id === this.currentUserId); + }, + createAwardList(name, list) { + return { + name, + list, + title: this.getAwardListTitle(list), + classes: this.getAwardClassBindings(list), + html: glEmojiTag(name), + }; + }, + getAwardListTitle(awardsList) { + const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList); + const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10; + let awardList = awardsList; + + // Filter myself from list if I am awarded. + if (hasReactionByCurrentUser) { + awardList = awardList.filter(award => award.user.id !== this.currentUserId); + } + + // Get only 9-10 usernames to show in tooltip text. + const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name); + + // Get the remaining list to use in `and x more` text. + const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length); + + // Add myself to the beginning of the list so title will start with You. + if (hasReactionByCurrentUser) { + namesToShow.unshift(__('You')); + } + + let title = ''; + + // We have 10+ awarded user, join them with comma and add `and x more`. + if (remainingAwardList.length) { + title = sprintf( + __(`%{listToShow}, and %{awardsListLength} more.`), + { + listToShow: namesToShow.join(', '), + awardsListLength: remainingAwardList.length, + }, + false, + ); + } else if (namesToShow.length > 1) { + // Join all names with comma but not the last one, it will be added with and text. + title = namesToShow.slice(0, namesToShow.length - 1).join(', '); + // If we have more than 2 users we need an extra comma before and text. + title += namesToShow.length > 2 ? ',' : ''; + title += sprintf(__(` and %{sliced}`), { sliced: namesToShow.slice(-1) }, false); // Append and text + } else { + // We have only 2 users so join them with and. + title = namesToShow.join(__(' and ')); + } + + return title; + }, + handleAward(awardName) { + if (!this.canAwardEmoji) { + return; + } + + // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string + const parsedName = /^[0-9]+$/.test(awardName) ? Number(awardName) : awardName; + + this.$emit('award', parsedName); + }, + }, +}; +</script> + +<template> + <div class="awards js-awards-block"> + <button + v-for="awardList in groupedAwards" + :key="awardList.name" + v-tooltip + :class="awardList.classes" + :title="awardList.title" + data-boundary="viewport" + data-testid="award-button" + class="btn award-control" + type="button" + @click="handleAward(awardList.name)" + > + <span data-testid="award-html" v-html="awardList.html"></span> + <span class="award-control-text js-counter">{{ awardList.list.length }}</span> + </button> + <div v-if="canAwardEmoji" class="award-menu-holder"> + <button + v-tooltip + :class="addButtonClass" + class="award-control btn js-add-award" + title="Add reaction" + :aria-label="__('Add reaction')" + data-boundary="viewport" + type="button" + > + <span class="award-control-icon award-control-icon-neutral"> + <gl-icon aria-hidden="true" name="slight-smile" /> + </span> + <span class="award-control-icon award-control-icon-positive"> + <gl-icon aria-hidden="true" name="smiley" /> + </span> + <span class="award-control-icon award-control-icon-super-positive"> + <gl-icon aria-hidden="true" name="smiley" /> + </span> + <i + aria-hidden="true" + class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading" + ></i> + </button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue index 3b9b9f37f52..7826c179889 100644 --- a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue @@ -3,7 +3,7 @@ import { GlNewDropdown, GlNewDropdownHeader, GlFormInputGroup, - GlNewButton, + GlButton, GlIcon, GlTooltipDirective, } from '@gitlab/ui'; @@ -15,7 +15,7 @@ export default { GlNewDropdown, GlNewDropdownHeader, GlFormInputGroup, - GlNewButton, + GlButton, GlIcon, }, directives: { @@ -55,13 +55,13 @@ export default { <div class="mx-3"> <gl-form-input-group :value="sshLink" readonly select-on-click> <template #append> - <gl-new-button + <gl-button v-gl-tooltip.hover :title="$options.copyURLTooltip" :data-clipboard-text="sshLink" > <gl-icon name="copy-to-clipboard" :title="$options.copyURLTooltip" /> - </gl-new-button> + </gl-button> </template> </gl-form-input-group> </div> @@ -73,13 +73,13 @@ export default { <div class="mx-3"> <gl-form-input-group :value="httpLink" readonly select-on-click> <template #append> - <gl-new-button + <gl-button v-gl-tooltip.hover :title="$options.copyURLTooltip" :data-clipboard-text="httpLink" > <gl-icon name="copy-to-clipboard" :title="$options.copyURLTooltip" /> - </gl-new-button> + </gl-button> </template> </gl-form-input-group> </div> diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue index cdcd5cdef7f..ffc616d7309 100644 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue +++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue @@ -158,7 +158,7 @@ export default { <template> <tooltip-on-truncate :title="timeWindowText" - :truncate-target="elem => elem.querySelector('.date-time-picker-toggle')" + :truncate-target="elem => elem.querySelector('.gl-dropdown-toggle-text')" placement="top" class="d-inline-block" > diff --git a/app/assets/javascripts/vue_shared/components/form/title.vue b/app/assets/javascripts/vue_shared/components/form/title.vue index f8f70529bd1..fad69dc1e24 100644 --- a/app/assets/javascripts/vue_shared/components/form/title.vue +++ b/app/assets/javascripts/vue_shared/components/form/title.vue @@ -10,6 +10,6 @@ export default { </script> <template> <gl-form-group :label="__('Title')" label-for="title-field-edit"> - <gl-form-input id="title-field-edit" v-bind="$attrs" v-on="$listeners" /> + <gl-form-input v-bind="$attrs" v-on="$listeners" /> </gl-form-group> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue index 913c971a512..040a15406e0 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue @@ -37,7 +37,7 @@ export default { :title="tooltipLabel" :class="cssClasses" type="button" - class="btn btn-blank gutter-toggle btn-sidebar-action" + class="btn btn-blank gutter-toggle btn-sidebar-action js-sidebar-vue-toggle" data-container="body" data-placement="left" data-boundary="viewport" diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index 602d4ab89e1..595baeeb14f 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -1,10 +1,8 @@ <script> -import { GlPopover, GlSkeletonLoading, GlSprintf } from '@gitlab/ui'; +import { GlPopover, GlSkeletonLoading } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; import { glEmojiTag } from '../../../emoji'; -import { s__ } from '~/locale'; -import { isString } from 'lodash'; export default { name: 'UserPopover', @@ -12,7 +10,6 @@ export default { Icon, GlPopover, GlSkeletonLoading, - GlSprintf, UserAvatarImage, }, props: { @@ -49,26 +46,7 @@ export default { return !this.user.name; }, workInformationIsLoading() { - return !this.user.loaded && this.workInformation === null; - }, - workInformation() { - const { jobTitle, organization } = this.user; - - if (organization && jobTitle) { - return { - message: s__('Profile|%{job_title} at %{organization}'), - placeholders: { job_title: jobTitle, organization }, - }; - } else if (organization) { - return organization; - } else if (jobTitle) { - return jobTitle; - } - - return null; - }, - workInformationShouldUseSprintf() { - return !isString(this.workInformation); + return !this.user.loaded && this.user.workInformation === null; }, locationIsLoading() { return !this.user.loaded && this.user.location === null; @@ -98,23 +76,13 @@ export default { <icon name="profile" class="category-icon flex-shrink-0" /> <span ref="bio" class="ml-1">{{ user.bio }}</span> </div> - <div v-if="workInformation" class="d-flex mb-1"> + <div v-if="user.workInformation" class="d-flex mb-1"> <icon v-show="!workInformationIsLoading" name="work" class="category-icon flex-shrink-0" /> - <span ref="workInformation" class="ml-1"> - <gl-sprintf v-if="workInformationShouldUseSprintf" :message="workInformation.message"> - <template - v-for="(placeholder, slotName) in workInformation.placeholders" - v-slot:[slotName] - > - <span :key="slotName">{{ placeholder }}</span> - </template> - </gl-sprintf> - <span v-else>{{ workInformation }}</span> - </span> + <span ref="workInformation" class="ml-1">{{ user.workInformation }}</span> </div> <gl-skeleton-loading v-if="workInformationIsLoading" diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss index 6820bdca2fa..ce1039832d3 100644 --- a/app/assets/stylesheets/components/related_items_list.scss +++ b/app/assets/stylesheets/components/related_items_list.scss @@ -73,6 +73,11 @@ $item-weight-max-width: 48px; .issue-token-state-icon-closed { display: none; } + + .sortable-link { + color: $gray-900; + font-weight: normal; + } } .item-path-id .path-id-text, @@ -249,6 +254,12 @@ $item-weight-max-width: 48px; line-height: 0; } +@include media-breakpoint-down(xs) { + .btn-sm.dropdown-toggle-split { + max-width: 40px; + } +} + @include media-breakpoint-up(sm) { .item-info-area { flex-basis: 100%; @@ -296,10 +307,6 @@ $item-weight-max-width: 48px; .item-meta { .item-meta-child { flex-basis: unset; - - ~ .item-assignees { - margin-left: $gl-padding-4; - } } } @@ -353,7 +360,7 @@ $item-weight-max-width: 48px; } .item-title-wrapper { - max-width: calc(100% - 440px); + max-width: calc(100% - 500px); } .item-info-area { @@ -407,7 +414,7 @@ $item-weight-max-width: 48px; } } -@media only screen and (min-width: 1400px) { +@media only screen and (min-width: 1500px) { .card-header, .item-body { .health-label-short { @@ -419,7 +426,9 @@ $item-weight-max-width: 48px; } } - .item-body .item-title-wrapper { - max-width: calc(100% - 570px); + .item-body { + .item-title-wrapper { + max-width: calc(100% - 640px); + } } } diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss index b1d79a41ba7..0292919ea50 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -105,10 +105,6 @@ } } - .js-ca-dropdown { - top: $gl-padding-top; - } - .stage-panel-body { display: flex; flex-wrap: wrap; diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss index af0afa9cc3b..f61245bed24 100644 --- a/app/assets/stylesheets/pages/prometheus.scss +++ b/app/assets/stylesheets/pages/prometheus.scss @@ -64,6 +64,12 @@ padding: $gl-padding-8 $gl-padding-12; } } + + .show-last-dropdown { + // same as in .dropdown-menu-toggle + // see app/assets/stylesheets/framework/dropdowns.scss + width: 160px; + } } .prometheus-panel { diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 2a811e08fd3..b829a7b518e 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -87,6 +87,11 @@ .gl-bg-orange-100 { @include gl-bg-orange-100; } .gl-bg-gray-100 { @include gl-bg-gray-100; } .gl-bg-green-100 { @include gl-bg-green-100;} +.gl-bg-blue-500 { @include gl-bg-blue-500; } +.gl-bg-green-500 { @include gl-bg-green-500; } +.gl-bg-theme-indigo-500 { @include gl-bg-theme-indigo-500; } +.gl-bg-red-500 { @include gl-bg-red-500; } +.gl-bg-orange-500 { @include gl-bg-orange-500; } .gl-text-blue-500 { @include gl-text-blue-500; } .gl-text-gray-500 { @include gl-text-gray-500; } |