diff options
Diffstat (limited to 'app/assets')
12 files changed, 338 insertions, 85 deletions
diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js index 56f64f934a1..720f30e18e6 100644 --- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js @@ -17,7 +17,7 @@ import flash from '~/flash'; export default function renderMermaid($els) { if (!$els.length) return; - import(/* webpackChunkName: 'mermaid' */ 'blackst0ne-mermaid') + import(/* webpackChunkName: 'mermaid' */ 'mermaid') .then(mermaid => { mermaid.initialize({ // mermaid core options diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js index 852d71f4e84..37a3ceb5341 100644 --- a/app/assets/javascripts/compare_autocomplete.js +++ b/app/assets/javascripts/compare_autocomplete.js @@ -40,7 +40,7 @@ export default function initCompareAutocomplete(limitTo = null, clickHandler = ( }, selectable: true, filterable: true, - filterRemote: true, + filterRemote: !!$dropdown.data('refsUrl'), fieldName: $dropdown.data('fieldName'), filterInput: 'input[type="search"]', renderRow: function(ref) { diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue index e2be805ed22..ec759043efc 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue @@ -25,14 +25,32 @@ export default { }, }, methods: { - createFile(target, file, isText) { + isText(content, fileType) { + const knownBinaryFileTypes = ['image/']; + const knownTextFileTypes = ['text/']; + const isKnownBinaryFileType = knownBinaryFileTypes.find(type => fileType.includes(type)); + const isKnownTextFileType = knownTextFileTypes.find(type => fileType.includes(type)); + const asciiRegex = /^[ -~\t\n\r]+$/; // tests whether a string contains ascii characters only (ranges from space to tilde, tabs and new lines) + + if (isKnownBinaryFileType) { + return false; + } + + if (isKnownTextFileType) { + return true; + } + + // if it's not a known file type, determine the type by evaluating the file contents + return asciiRegex.test(content); + }, + createFile(target, file) { const { name } = file; let { result } = target; + const encodedContent = result.split('base64,')[1]; + const rawContent = encodedContent ? atob(encodedContent) : ''; + const isText = this.isText(rawContent, file.type); - if (!isText) { - // eslint-disable-next-line prefer-destructuring - result = result.split('base64,')[1]; - } + result = isText ? rawContent : encodedContent; this.$emit('create', { name: `${this.path ? `${this.path}/` : ''}${name}`, @@ -43,15 +61,9 @@ export default { }, readFile(file) { const reader = new FileReader(); - const isText = file.type.match(/text.*/) !== null; - reader.addEventListener('load', e => this.createFile(e.target, file, isText), { once: true }); - - if (isText) { - reader.readAsText(file); - } else { - reader.readAsDataURL(file); - } + reader.addEventListener('load', e => this.createFile(e.target, file), { once: true }); + reader.readAsDataURL(file); }, openFile() { Array.from(this.$refs.fileUpload.files).forEach(file => this.readFile(file)); diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job_container_item.vue new file mode 100644 index 00000000000..81cc0823792 --- /dev/null +++ b/app/assets/javascripts/jobs/components/job_container_item.vue @@ -0,0 +1,66 @@ +<script> +import _ from 'underscore'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; + +export default { + components: { + CiIcon, + Icon, + }, + + directives: { + tooltip, + }, + + props: { + job: { + type: Object, + required: true, + }, + isActive: { + type: Boolean, + required: true, + }, + }, + + computed: { + tooltipText() { + return `${_.escape(this.job.name)} - ${this.job.status.tooltip}`; + }, + }, +}; +</script> + +<template> + <div + class="build-job" + :class="{ retried: job.retried, active: isActive }" + > + <a + v-tooltip + :href="job.status.details_path" + :title="tooltipText" + data-container="body" + data-boundary="viewport" + class="js-job-link" + > + <icon + v-if="isActive" + name="arrow-right" + class="js-arrow-right icon-arrow-right" + /> + + <ci-icon :status="job.status" /> + + <span>{{ job.name ? job.name : job.id }}</span> + + <icon + v-if="job.retried" + name="retry" + class="js-retry-icon" + /> + </a> + </div> +</template> diff --git a/app/assets/javascripts/jobs/components/jobs_container.vue b/app/assets/javascripts/jobs/components/jobs_container.vue index 03f36ec5c8b..951bcb36600 100644 --- a/app/assets/javascripts/jobs/components/jobs_container.vue +++ b/app/assets/javascripts/jobs/components/jobs_container.vue @@ -1,17 +1,11 @@ <script> -import _ from 'underscore'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import Icon from '~/vue_shared/components/icon.vue'; -import tooltip from '~/vue_shared/directives/tooltip'; +import JobContainerItem from './job_container_item.vue'; export default { components: { - CiIcon, - Icon, - }, - directives: { - tooltip, + JobContainerItem, }, + props: { jobs: { type: Array, @@ -26,49 +20,16 @@ export default { isJobActive(currentJobId) { return this.jobId === currentJobId; }, - tooltipText(job) { - return `${_.escape(job.name)} - ${job.status.tooltip}`; - }, }, }; </script> <template> <div class="js-jobs-container builds-container"> - <div + <job-container-item v-for="job in jobs" :key="job.id" - class="build-job" - :class="{ retried: job.retried, active: isJobActive(job.id) }" - > - <a - v-tooltip - :href="job.status.details_path" - :title="tooltipText(job)" - data-container="body" - > - <icon - v-if="isJobActive(job.id)" - name="arrow-right" - class="js-arrow-right icon-arrow-right" - /> - - <ci-icon :status="job.status" /> - - <span> - <template v-if="job.name"> - {{ job.name }} - </template> - <template v-else> - {{ job.id }} - </template> - </span> - - <icon - v-if="job.retried" - name="retry" - class="js-retry-icon" - /> - </a> - </div> + :job="job" + :is-active="isJobActive(job.id)" + /> </div> </template> diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js index d0bce857029..32b55575f95 100644 --- a/app/assets/javascripts/pages/groups/edit/index.js +++ b/app/assets/javascripts/pages/groups/edit/index.js @@ -5,6 +5,7 @@ import initSettingsPanels from '~/settings_panels'; import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; import { GROUP_BADGE } from '~/badges/constants'; +import projectSelect from '~/project_select'; document.addEventListener('DOMContentLoaded', () => { groupAvatar(); @@ -15,4 +16,6 @@ document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.js-general-settings-form, .js-general-permissions-form'), ); mountBadgeSettings(GROUP_BADGE); + + projectSelect(); }); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue index 9161f703697..6c87287a4c4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue @@ -1,6 +1,7 @@ <script> import Icon from '~/vue_shared/components/icon.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import FilteredSearchDropdown from '~/vue_shared/components/filtered_search_dropdown.vue'; import timeagoMixin from '../../vue_shared/mixins/timeago'; import tooltip from '../../vue_shared/directives/tooltip'; import LoadingButton from '../../vue_shared/components/loading_button.vue'; @@ -18,6 +19,7 @@ export default { StatusIcon, Icon, TooltipOnTruncate, + FilteredSearchDropdown, }, directives: { tooltip, @@ -30,8 +32,10 @@ export default { }, }, data() { + const features = window.gon.features || {}; return { isStopping: false, + enableCiEnvironmentsStatusChanges: features.ciEnvironmentsStatusChanges, }; }, computed: { @@ -118,18 +122,65 @@ export default { /> </div> <div> - <a - v-if="hasExternalUrls" - :href="deployment.external_url" - target="_blank" - rel="noopener noreferrer nofollow" - class="deploy-link js-deploy-url btn btn-default btn-sm inline" - > - <span> - View app - <icon name="external-link" /> - </span> - </a> + <template v-if="hasExternalUrls"> + <filtered-search-dropdown + v-if="enableCiEnvironmentsStatusChanges" + class="js-mr-wigdet-deployment-dropdown inline" + :items="deployment.changes" + :main-action-link="deployment.external_url" + filter-key="path" + > + <template + slot="mainAction" + slot-scope="slotProps" + > + <a + :href="deployment.external_url" + target="_blank" + rel="noopener noreferrer nofollow" + class="deploy-link js-deploy-url inline" + :class="slotProps.className" + > + <span> + {{ __('View app') }} + <icon name="external-link" /> + </span> + </a> + </template> + + <template + slot="result" + slot-scope="slotProps" + > + <a + :href="slotProps.result.external_url" + target="_blank" + rel="noopener noreferrer nofollow" + class="menu-item" + > + <strong class="str-truncated-100 append-bottom-0 d-block"> + {{ slotProps.result.path }} + </strong> + + <p class="text-secondary str-truncated-100 append-bottom-0 d-block"> + {{ slotProps.result.external_url }} + </p> + </a> + </template> + </filtered-search-dropdown> + <a + v-else + :href="deployment.external_url" + target="_blank" + rel="noopener noreferrer nofollow" + class="js-deploy-url js-deploy-url-feature-flag deploy-link btn btn-default btn-sm inline" + > + <span> + {{ __('View app') }} + <icon name="external-link" /> + </span> + </a> + </template> <loading-button v-if="deployment.stop_url" :loading="isStopping" diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index a599cc002d6..8180f13a7cb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -112,7 +112,8 @@ export default { eventHub.$on('mr.discussion.updated', this.checkStatus); }, mounted() { - this.handleMounted(); + this.setFaviconHelper(); + this.initDeploymentsPolling(); }, beforeDestroy() { eventHub.$off('mr.discussion.updated', this.checkStatus); @@ -250,10 +251,6 @@ export default { this.stopPolling(); }); }, - handleMounted() { - this.setFaviconHelper(); - this.initDeploymentsPolling(); - }, }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue b/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue new file mode 100644 index 00000000000..460fa6ad72e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue @@ -0,0 +1,143 @@ +<script> +import $ from 'jquery'; +import Icon from '~/vue_shared/components/icon.vue'; +/** + * Renders a split dropdown with + * an input that allows to search through the given + * array of options. + */ +export default { + name: 'FilteredSearchDropdown', + components: { + Icon, + }, + props: { + title: { + type: String, + required: false, + default: '', + }, + buttonType: { + required: false, + validator: value => + ['primary', 'default', 'secondary', 'success', 'info', 'warning', 'danger'].indexOf( + value, + ) !== -1, + default: 'default', + }, + size: { + required: false, + type: String, + default: 'sm', + }, + items: { + type: Array, + required: true, + }, + visibleItems: { + type: Number, + required: false, + default: 5, + }, + filterKey: { + type: String, + required: true, + }, + }, + data() { + return { + filter: '', + }; + }, + computed: { + className() { + return `btn btn-${this.buttonType} btn-${this.size}`; + }, + filteredResults() { + if (this.filter !== '') { + return this.items.filter( + item => item[this.filterKey] && item[this.filterKey].toLowerCase().includes(this.filter.toLowerCase()), + ); + } + + return this.items.slice(0, this.visibleItems); + } + }, + mounted() { + /** + * Resets the filter every time the user closes the dropdown + */ + $(this.$el) + .on('shown.bs.dropdown', () => { + this.$nextTick(() => this.$refs.searchInput.focus()); + }) + .on('hidden.bs.dropdown', () => { + this.filter = ''; + }); + }, +}; +</script> +<template> + <div class="dropdown"> + <div class="btn-group"> + <slot + name="mainAction" + :class-name="className" + > + <button + type="button" + :class="className" + > + {{ title }} + </button> + </slot> + + <button + type="button" + :class="className" + class="dropdown-toggle dropdown-toggle-split" + data-toggle="dropdown" + aria-haspopup="true" + aria-expanded="false" + aria-label="Expand dropdown" + > + <icon + name="angle-down" + :size="12" + /> + </button> + <div class="dropdown-menu dropdown-menu-right"> + <div class="dropdown-input"> + <input + ref="searchInput" + v-model="filter" + type="search" + placeholder="Filter" + class="js-filtered-dropdown-input dropdown-input-field" + /> + <icon + class="dropdown-input-search" + name="search" + /> + </div> + + <div class="dropdown-content"> + <ul> + <li + v-for="(result, i) in filteredResults" + :key="i" + class="js-filtered-dropdown-result" + > + <slot + name="result" + :result="result" + > + {{ result[filterKey] }} + </slot> + </li> + </ul> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 4ffb3e9ab42..4041f2b4479 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -51,6 +51,7 @@ @import 'framework/blank'; @import 'framework/wells'; @import 'framework/page_header'; +@import 'framework/page_title'; @import 'framework/awards'; @import 'framework/images'; @import 'framework/broadcast_messages'; diff --git a/app/assets/stylesheets/framework/page_title.scss b/app/assets/stylesheets/framework/page_title.scss new file mode 100644 index 00000000000..e8302953a63 --- /dev/null +++ b/app/assets/stylesheets/framework/page_title.scss @@ -0,0 +1,18 @@ +.page-title-holder { + @extend .d-flex; + @extend .align-items-center; + + padding-top: $gl-padding-top; + border-bottom: 1px solid $border-color; + + .page-title { + margin: $gl-padding 0; + font-size: 1.75em; + font-weight: $gl-font-weight-bold; + color: $gl-text-color; + } + + .page-title-controls { + margin-left: auto; + } +} diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 895db89f289..2feb7464ecb 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -47,7 +47,6 @@ } } - .mr-widget-heading { position: relative; border: 1px solid $border-color; @@ -454,7 +453,7 @@ .mr-list { .merge-request { - padding: 10px 0 10px 15px; + padding: 10px 0 10px 15px; position: relative; display: -webkit-flex; display: flex; @@ -468,7 +467,6 @@ margin-bottom: 2px; .ci-status-link { - svg { height: 16px; width: 16px; @@ -698,7 +696,6 @@ .table-holder { .ci-table { - th { background-color: $white-light; color: $gl-text-color-secondary; @@ -775,7 +772,7 @@ &.affix { left: 0; - transition: right .15s; + transition: right 0.15s; @include media-breakpoint-down(xs) { right: 0; @@ -884,7 +881,7 @@ } > *:not(:last-child) { - margin-right: .3em; + margin-right: 0.3em; } svg { @@ -907,6 +904,10 @@ .btn svg { fill: $theme-gray-700; } + + .dropdown-menu { + width: 400px; + } } // Hack alert: we've rewritten `btn` class in a way that @@ -917,7 +918,7 @@ &[disabled] { cursor: not-allowed; box-shadow: none; - opacity: .65; + opacity: 0.65; &:hover { color: $gl-gray-500; |