diff options
Diffstat (limited to 'app/assets/javascripts/vue_shared')
53 files changed, 1560 insertions, 1155 deletions
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue index 3c73f42b6b1..634b7da3def 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue @@ -34,7 +34,7 @@ export default { <template> <li :id="noteAnchorId" class="timeline-entry note system-note note-wrapper gl-p-0!"> - <div class="gl-display-inline-flex gl-align-items-center"> + <div class="gl-display-inline-flex gl-align-items-center gl-relative"> <div class="gl-display-inline gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-box-sizing-content-box gl-p-3 gl-mt-n2 gl-mr-6" > diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue index 49181bb847d..3a3929fba9b 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue @@ -16,7 +16,7 @@ export default { handleBlobRichViewer(this.$refs.content, this.type); }, safeHtmlConfig: { - ADD_TAGS: ['copy-code'], + ADD_TAGS: ['gl-emoji', 'copy-code'], }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue index 271cfd210a6..52a5d6e1b86 100644 --- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue +++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue @@ -42,11 +42,6 @@ export default { required: false, default: true, }, - iconClasses: { - type: String, - required: false, - default: '', - }, }, computed: { title() { @@ -73,7 +68,7 @@ export default { :href="detailsPath" @click="$emit('ciStatusBadgeClick')" > - <ci-icon :status="status" :css-classes="iconClasses" /> + <ci-icon :status="status" /> <template v-if="showText"> <span class="gl-ml-2">{{ status.text }}</span> diff --git a/app/assets/javascripts/vue_shared/components/entity_select/constants.js b/app/assets/javascripts/vue_shared/components/entity_select/constants.js new file mode 100644 index 00000000000..0fb5a2d5534 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/entity_select/constants.js @@ -0,0 +1,16 @@ +import { __, s__ } from '~/locale'; + +export const RESET_LABEL = __('Reset'); +export const QUERY_TOO_SHORT_MESSAGE = __('Enter at least three characters to search.'); + +// Groups +export const GROUP_TOGGLE_TEXT = __('Search for a group'); +export const GROUP_HEADER_TEXT = __('Select a group'); +export const FETCH_GROUPS_ERROR = __('Unable to fetch groups. Reload the page to try again.'); +export const FETCH_GROUP_ERROR = __('Unable to fetch group. Reload the page to try again.'); + +// Projects +export const PROJECT_TOGGLE_TEXT = s__('ProjectSelect|Search for project'); +export const PROJECT_HEADER_TEXT = s__('ProjectSelect|Select a project'); +export const FETCH_PROJECTS_ERROR = __('Unable to fetch projects. Reload the page to try again.'); +export const FETCH_PROJECT_ERROR = __('Unable to fetch project. Reload the page to try again.'); diff --git a/app/assets/javascripts/vue_shared/components/group_select/group_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue index d295052e2ce..45c50dce8ce 100644 --- a/app/assets/javascripts/vue_shared/components/group_select/group_select.vue +++ b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue @@ -1,28 +1,15 @@ <script> import { debounce } from 'lodash'; -import { GlFormGroup, GlAlert, GlCollapsibleListbox } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; -import axios from '~/lib/utils/axios_utils'; -import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; -import Api from '~/api'; +import { GlFormGroup, GlCollapsibleListbox } from '@gitlab/ui'; import { __ } from '~/locale'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { groupsPath } from './utils'; -import { - TOGGLE_TEXT, - RESET_LABEL, - FETCH_GROUPS_ERROR, - FETCH_GROUP_ERROR, - QUERY_TOO_SHORT_MESSAGE, -} from './constants'; +import { RESET_LABEL, QUERY_TOO_SHORT_MESSAGE } from './constants'; const MINIMUM_QUERY_LENGTH = 3; -const GROUPS_PER_PAGE = 20; export default { components: { GlFormGroup, - GlAlert, GlCollapsibleListbox, }, props: { @@ -48,13 +35,20 @@ export default { required: false, default: false, }, - parentGroupID: { + headerText: { type: String, - required: false, - default: null, + required: true, }, - groupsFilter: { + defaultToggleText: { type: String, + required: true, + }, + fetchItems: { + type: Function, + required: true, + }, + fetchInitialSelectionText: { + type: Function, required: false, default: null, }, @@ -63,10 +57,10 @@ export default { return { pristine: true, searching: false, - hasMoreGroups: true, + hasMoreItems: true, infiniteScrollLoading: false, searchString: '', - groups: [], + items: [], page: 1, selectedValue: null, selectedText: null, @@ -78,14 +72,14 @@ export default { set(value) { this.selectedValue = value; this.selectedText = - value === null ? null : this.groups.find((group) => group.value === value).full_name; + value === null ? null : this.items.find((item) => item.value === value).text; }, get() { return this.selectedValue; }, }, toggleText() { - return this.selectedText ?? this.$options.i18n.toggleText; + return this.selectedText ?? this.defaultToggleText; }, resetButtonLabel() { return this.clearable ? RESET_LABEL : ''; @@ -109,90 +103,64 @@ export default { search: debounce(function debouncedSearch(searchString) { this.searchString = searchString; if (this.isSearchQueryTooShort) { - this.groups = []; + this.items = []; } else { - this.fetchGroups(); + this.fetchEntities(); } }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), - async fetchGroups(page = 1) { + async fetchEntities(page = 1) { if (page === 1) { this.searching = true; - this.groups = []; - this.hasMoreGroups = true; + this.items = []; + this.hasMoreItems = true; } else { this.infiniteScrollLoading = true; } - try { - const { data, headers } = await axios.get( - Api.buildUrl(groupsPath(this.groupsFilter, this.parentGroupID)), - { - params: { - search: this.searchString, - per_page: GROUPS_PER_PAGE, - page, - }, - }, - ); - const groups = data.length ? data : data.results || []; - - this.groups.push( - ...groups.map((group) => ({ - ...group, - value: String(group.id), - })), - ); + const { items, totalPages } = await this.fetchItems(this.searchString, page); - const { totalPages } = parseIntPagination(normalizeHeaders(headers)); - if (page === totalPages) { - this.hasMoreGroups = false; - } + this.items.push(...items); - this.page = page; - this.searching = false; - this.infiniteScrollLoading = false; - } catch (error) { - this.handleError({ message: FETCH_GROUPS_ERROR, error }); + if (page === totalPages) { + this.hasMoreItems = false; } + + this.page = page; + this.searching = false; + this.infiniteScrollLoading = false; }, async fetchInitialSelection() { if (!this.initialSelection) { this.pristine = false; return; } - this.searching = true; - try { - const group = await Api.group(this.initialSelection); - this.selectedValue = this.initialSelection; - this.selectedText = group.full_name; - this.pristine = false; - this.searching = false; - } catch (error) { - this.handleError({ message: FETCH_GROUP_ERROR, error }); + + if (!this.fetchInitialSelectionText) { + throw new Error( + '`initialSelection` is provided but lacks `fetchInitialSelectionText` to retrieve the corresponding text', + ); } + + this.searching = true; + const name = await this.fetchInitialSelectionText(this.initialSelection); + this.selectedValue = this.initialSelection; + this.selectedText = name; + this.pristine = false; + this.searching = false; }, onShown() { - if (!this.searchString && !this.groups.length) { - this.fetchGroups(); + if (!this.searchString && !this.items.length) { + this.fetchEntities(); } }, onReset() { this.selected = null; }, onBottomReached() { - this.fetchGroups(this.page + 1); - }, - handleError({ message, error }) { - Sentry.captureException(error); - this.errorMessage = message; - }, - dismissError() { - this.errorMessage = ''; + this.fetchEntities(this.page + 1); }, }, i18n: { - toggleText: TOGGLE_TEXT, - selectGroup: __('Select a group'), noResultsText: __('No results found.'), searchQueryTooShort: QUERY_TOO_SHORT_MESSAGE, }, @@ -201,20 +169,21 @@ export default { <template> <gl-form-group :label="label"> - <gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" @dismiss="dismissError">{{ - errorMessage - }}</gl-alert> + <slot name="error"></slot> + <template v-if="Boolean($scopedSlots.label)" #label> + <slot name="label"></slot> + </template> <gl-collapsible-listbox ref="listbox" v-model="selected" - :header-text="$options.i18n.selectGroup" + :header-text="headerText" :reset-button-label="resetButtonLabel" :toggle-text="toggleText" :loading="searching && pristine" :searching="searching" - :items="groups" + :items="items" :no-results-text="noResultsText" - :infinite-scroll="hasMoreGroups" + :infinite-scroll="hasMoreItems" :infinite-scroll-loading="infiniteScrollLoading" searchable @shown="onShown" @@ -223,10 +192,7 @@ export default { @bottom-reached="onBottomReached" > <template #list-item="{ item }"> - <div class="gl-font-weight-bold"> - {{ item.full_name }} - </div> - <div class="gl-text-gray-300">{{ item.full_path }}</div> + <slot name="list-item" :item="item"></slot> </template> </gl-collapsible-listbox> <input :id="inputId" data-testid="input" type="hidden" :name="inputName" :value="inputValue" /> diff --git a/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue new file mode 100644 index 00000000000..ff137d764ee --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue @@ -0,0 +1,137 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import axios from '~/lib/utils/axios_utils'; +import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; +import Api, { DEFAULT_PER_PAGE } from '~/api'; +import { groupsPath } from './utils'; +import { + GROUP_TOGGLE_TEXT, + GROUP_HEADER_TEXT, + FETCH_GROUPS_ERROR, + FETCH_GROUP_ERROR, +} from './constants'; +import EntitySelect from './entity_select.vue'; + +export default { + components: { + GlAlert, + EntitySelect, + }, + props: { + label: { + type: String, + required: false, + default: '', + }, + inputName: { + type: String, + required: true, + }, + inputId: { + type: String, + required: true, + }, + initialSelection: { + type: String, + required: false, + default: null, + }, + clearable: { + type: Boolean, + required: false, + default: false, + }, + parentGroupID: { + type: String, + required: false, + default: null, + }, + groupsFilter: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + errorMessage: '', + }; + }, + methods: { + async fetchGroups(searchString = '', page = 1) { + let groups = []; + let totalPages = 0; + try { + const { data = [], headers } = await axios.get( + Api.buildUrl(groupsPath(this.groupsFilter, this.parentGroupID)), + { + params: { + search: searchString, + per_page: DEFAULT_PER_PAGE, + page, + }, + }, + ); + groups = data.map((group) => ({ + ...group, + text: group.full_name, + value: String(group.id), + })); + + totalPages = parseIntPagination(normalizeHeaders(headers)).totalPages; + } catch (error) { + this.handleError({ message: FETCH_GROUPS_ERROR, error }); + } + return { items: groups, totalPages }; + }, + async fetchGroupName(groupId) { + let groupName = ''; + try { + const group = await Api.group(groupId); + groupName = group.full_name; + } catch (error) { + this.handleError({ message: FETCH_GROUP_ERROR, error }); + } + return groupName; + }, + handleError({ message, error }) { + Sentry.captureException(error); + this.errorMessage = message; + }, + dismissError() { + this.errorMessage = ''; + }, + }, + i18n: { + toggleText: GROUP_TOGGLE_TEXT, + selectGroup: GROUP_HEADER_TEXT, + }, +}; +</script> + +<template> + <entity-select + :label="label" + :input-name="inputName" + :input-id="inputId" + :initial-selection="initialSelection" + :clearable="clearable" + :header-text="$options.i18n.selectGroup" + :default-toggle-text="$options.i18n.toggleText" + :fetch-items="fetchGroups" + :fetch-initial-selection-text="fetchGroupName" + > + <template #error> + <gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" @dismiss="dismissError">{{ + errorMessage + }}</gl-alert> + </template> + <template #list-item="{ item }"> + <div class="gl-font-weight-bold"> + {{ item.full_name }} + </div> + <div class="gl-text-gray-300">{{ item.full_path }}</div> + </template> + </entity-select> +</template> diff --git a/app/assets/javascripts/vue_shared/components/group_select/init_group_selects.js b/app/assets/javascripts/vue_shared/components/entity_select/init_group_selects.js index dbfac8a0339..dbfac8a0339 100644 --- a/app/assets/javascripts/vue_shared/components/group_select/init_group_selects.js +++ b/app/assets/javascripts/vue_shared/components/entity_select/init_group_selects.js diff --git a/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js b/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js new file mode 100644 index 00000000000..1afbeda74c4 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js @@ -0,0 +1,48 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import ProjectSelect from './project_select.vue'; + +const SELECTOR = '.js-vue-project-select'; + +export const initProjectSelects = () => { + if (process.env.NODE_ENV !== 'production' && document.querySelector(SELECTOR) === null) { + // eslint-disable-next-line no-console + console.warn(`Attempted to initialize ProjectSelect but '${SELECTOR}' not found in the page`); + } + + document.querySelectorAll(SELECTOR).forEach((el) => { + const { + label, + inputName, + inputId, + groupId, + userId, + orderBy, + selected: initialSelection, + } = el.dataset; + const includeSubgroups = parseBoolean(el.dataset.includeSubgroups); + const membership = parseBoolean(el.dataset.membership); + const hasHtmlLabel = parseBoolean(el.dataset.hasHtmlLabel); + + return new Vue({ + el, + name: 'ProjectSelectRoot', + render(createElement) { + return createElement(ProjectSelect, { + props: { + label, + hasHtmlLabel, + inputName, + inputId, + groupId, + userId, + orderBy, + includeSubgroups, + membership, + initialSelection, + }, + }); + }, + }); + }); +}; diff --git a/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue new file mode 100644 index 00000000000..393991d746e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue @@ -0,0 +1,168 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import Api from '~/api'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { + PROJECT_TOGGLE_TEXT, + PROJECT_HEADER_TEXT, + FETCH_PROJECTS_ERROR, + FETCH_PROJECT_ERROR, +} from './constants'; +import EntitySelector from './entity_select.vue'; + +export default { + components: { + GlAlert, + EntitySelector, + }, + directives: { + SafeHtml, + }, + props: { + label: { + type: String, + required: true, + }, + hasHtmlLabel: { + type: Boolean, + required: false, + default: false, + }, + inputName: { + type: String, + required: true, + }, + inputId: { + type: String, + required: true, + }, + groupId: { + type: String, + required: false, + default: null, + }, + userId: { + type: String, + required: false, + default: null, + }, + includeSubgroups: { + type: Boolean, + required: false, + default: false, + }, + membership: { + type: Boolean, + required: false, + default: false, + }, + orderBy: { + type: String, + required: false, + default: 'similarity', + }, + initialSelection: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + errorMessage: '', + }; + }, + methods: { + async fetchProjects(searchString = '') { + let projects = []; + try { + const { data = [] } = await (() => { + const commonParams = { + order_by: this.orderBy, + simple: true, + }; + + if (this.groupId) { + return Api.groupProjects(this.groupId, searchString, { + ...commonParams, + with_shared: true, + include_subgroups: this.includeSubgroups, + simple: true, + }); + } + // Note: the whole userId handling supports a single project selector that is slated for + // removal. Once we have deleted app/views/clusters/clusters/_advanced_settings.html.haml, + // we should be able to clean this up. + if (this.userId) { + return Api.userProjects( + this.userId, + searchString, + { + with_shared: true, + include_subgroups: this.includeSubgroups, + }, + (res) => ({ data: res }), + ); + } + return Api.projects(searchString, { + ...commonParams, + membership: this.membership, + }); + })(); + projects = data.map((item) => ({ + text: item.name_with_namespace || item.name, + value: String(item.id), + })); + } catch (error) { + this.handleError({ message: FETCH_PROJECTS_ERROR, error }); + } + return { items: projects, totalPages: 1 }; + }, + async fetchProjectName(projectId) { + let projectName = ''; + try { + const { data: project } = await Api.project(projectId); + projectName = project.name_with_namespace; + } catch (error) { + this.handleError({ message: FETCH_PROJECT_ERROR, error }); + } + return projectName; + }, + handleError({ message, error }) { + Sentry.captureException(error); + this.errorMessage = message; + }, + dismissError() { + this.errorMessage = ''; + }, + }, + i18n: { + searchForProject: PROJECT_TOGGLE_TEXT, + selectProject: PROJECT_HEADER_TEXT, + }, +}; +</script> + +<template> + <entity-selector + :label="label" + :input-name="inputName" + :input-id="inputId" + :initial-selection="initialSelection" + :header-text="$options.i18n.selectProject" + :default-toggle-text="$options.i18n.searchForProject" + :fetch-items="fetchProjects" + :fetch-initial-selection-text="fetchProjectName" + clearable + > + <template v-if="hasHtmlLabel" #label> + <span v-safe-html="label"></span> + </template> + <template #error> + <gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" @dismiss="dismissError">{{ + errorMessage + }}</gl-alert> + </template> + </entity-selector> +</template> diff --git a/app/assets/javascripts/vue_shared/components/group_select/utils.js b/app/assets/javascripts/vue_shared/components/entity_select/utils.js index 0a4622269f4..0a4622269f4 100644 --- a/app/assets/javascripts/vue_shared/components/group_select/utils.js +++ b/app/assets/javascripts/vue_shared/components/entity_select/utils.js diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue index adf34f822ed..6a10557c6bc 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/file_icon.vue @@ -1,7 +1,7 @@ <script> +import { getIconForFile } from '@gitlab/svgs/src/file_icon_map'; import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; import { FILE_SYMLINK_MODE } from '../constants'; -import getIconForFile from './file_icon/file_icon_map'; /* This is a re-usable vue component for rendering a svg sprite icon @@ -88,7 +88,7 @@ export default { <gl-loading-icon v-if="loading" size="sm" :inline="true" /> <gl-icon v-else-if="isSymlink" name="symlink" :size="size" /> <svg v-else-if="!folder" :key="spriteHref" :class="[iconSizeClass, cssClasses]"> - <use v-bind="{ 'xlink:href': spriteHref }" /> + <use :href="spriteHref" /> </svg> <gl-icon v-else diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js deleted file mode 100644 index 8686d317c8a..00000000000 --- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js +++ /dev/null @@ -1,610 +0,0 @@ -const fileExtensionIcons = { - html: 'html', - htm: 'html', - html_vm: 'html', - asp: 'html', - jade: 'pug', - pug: 'pug', - md: 'markdown', - markdown: 'markdown', - mdown: 'markdown', - mkd: 'markdown', - mkdn: 'markdown', - rst: 'markdown', - blink: 'blink', - css: 'css', - scss: 'sass', - sass: 'sass', - less: 'less', - json: 'json', - yaml: 'yaml', - yml: 'yaml', - xml: 'xml', - plist: 'xml', - xsd: 'xml', - dtd: 'xml', - xsl: 'xml', - xslt: 'xml', - resx: 'xml', - iml: 'xml', - xquery: 'xml', - tmLanguage: 'xml', - manifest: 'xml', - project: 'xml', - png: 'image', - jpeg: 'image', - jpg: 'image', - gif: 'image', - svg: 'image', - ico: 'image', - tif: 'image', - tiff: 'image', - psd: 'image', - psb: 'image', - ami: 'image', - apx: 'image', - bmp: 'image', - bpg: 'image', - brk: 'image', - cur: 'image', - dds: 'image', - dng: 'image', - exr: 'image', - fpx: 'image', - gbr: 'image', - img: 'image', - jbig2: 'image', - jb2: 'image', - jng: 'image', - jxr: 'image', - pbm: 'image', - pgf: 'image', - pic: 'image', - raw: 'image', - webp: 'image', - js: 'javascript', - ejs: 'javascript', - esx: 'javascript', - jsx: 'react', - tsx: 'react', - ini: 'settings', - dlc: 'settings', - dll: 'settings', - config: 'settings', - conf: 'settings', - properties: 'settings', - prop: 'settings', - settings: 'settings', - option: 'settings', - props: 'settings', - toml: 'settings', - prefs: 'settings', - ts: 'typescript', - marko: 'markojs', - pdf: 'pdf', - xlsx: 'table', - xls: 'table', - ods: 'table', - csv: 'table', - tsv: 'table', - vscodeignore: 'vscode', - vsixmanifest: 'vscode', - vsix: 'vscode', - suo: 'visualstudio', - sln: 'visualstudio', - csproj: 'visualstudio', - vb: 'visualstudio', - pdb: 'database', - sql: 'database', - pks: 'database', - pkb: 'database', - accdb: 'database', - mdb: 'database', - sqlite: 'database', - cs: 'csharp', - zip: 'zip', - tar: 'zip', - gz: 'zip', - xz: 'zip', - bzip2: 'zip', - gzip: 'zip', - rar: 'zip', - tgz: 'zip', - exe: 'exe', - msi: 'exe', - java: 'java', - jar: 'java', - jsp: 'java', - c: 'c', - m: 'c', - h: 'h', - cc: 'cpp', - cpp: 'cpp', - mm: 'cpp', - cxx: 'cpp', - hpp: 'hpp', - go: 'go', - py: 'python', - url: 'url', - sh: 'console', - ksh: 'console', - csh: 'console', - tcsh: 'console', - zsh: 'console', - bash: 'console', - bat: 'console', - cmd: 'console', - ps1: 'powershell', - psm1: 'powershell', - psd1: 'powershell', - ps1xml: 'powershell', - psc1: 'powershell', - pssc: 'powershell', - gradle: 'gradle', - doc: 'word', - docx: 'word', - odt: 'word', - rtf: 'word', - cer: 'certificate', - cert: 'certificate', - crt: 'certificate', - pub: 'key', - key: 'key', - pem: 'key', - asc: 'key', - gpg: 'key', - woff: 'font', - woff2: 'font', - ttf: 'font', - eot: 'font', - suit: 'font', - otf: 'font', - bmap: 'font', - fnt: 'font', - odttf: 'font', - ttc: 'font', - font: 'font', - fonts: 'font', - sui: 'font', - ntf: 'font', - mrf: 'font', - lib: 'lib', - bib: 'lib', - rb: 'ruby', - erb: 'ruby', - fs: 'fsharp', - fsx: 'fsharp', - fsi: 'fsharp', - fsproj: 'fsharp', - swift: 'swift', - ino: 'arduino', - dockerignore: 'docker', - dockerfile: 'docker', - tex: 'tex', - cls: 'tex', - sty: 'tex', - pptx: 'powerpoint', - ppt: 'powerpoint', - pptm: 'powerpoint', - potx: 'powerpoint', - pot: 'powerpoint', - potm: 'powerpoint', - ppsx: 'powerpoint', - ppsm: 'powerpoint', - pps: 'powerpoint', - ppam: 'powerpoint', - ppa: 'powerpoint', - odp: 'powerpoint', - webm: 'movie', - mkv: 'movie', - flv: 'movie', - vob: 'movie', - ogv: 'movie', - ogg: 'music', - gifv: 'movie', - avi: 'movie', - mov: 'movie', - qt: 'movie', - wmv: 'movie', - yuv: 'movie', - rm: 'movie', - rmvb: 'movie', - mp4: 'movie', - m4v: 'movie', - mpg: 'movie', - mp2: 'movie', - mpeg: 'movie', - mpe: 'movie', - mpv: 'movie', - m2v: 'movie', - vdi: 'virtual', - vbox: 'virtual', - ics: 'email', - mp3: 'music', - flac: 'music', - m4a: 'music', - wma: 'music', - aiff: 'music', - coffee: 'coffee', - txt: 'document', - graphql: 'graphql', - rs: 'rust', - raml: 'raml', - xaml: 'xaml', - hs: 'haskell', - kt: 'kotlin', - kts: 'kotlin', - patch: 'git', - lua: 'lua', - clj: 'clojure', - cljs: 'clojure', - groovy: 'groovy', - r: 'r', - rmd: 'r', - dart: 'dart', - as: 'actionscript', - mxml: 'mxml', - ahk: 'autohotkey', - swf: 'flash', - swc: 'swc', - cmake: 'cmake', - asm: 'assembly', - a51: 'assembly', - inc: 'assembly', - nasm: 'assembly', - s: 'assembly', - ms: 'assembly', - agc: 'assembly', - ags: 'assembly', - aea: 'assembly', - argus: 'assembly', - mitigus: 'assembly', - binsource: 'assembly', - vue: 'vue', - ml: 'ocaml', - mli: 'ocaml', - cmx: 'ocaml', - lock: 'lock', - hbs: 'handlebars', - mustache: 'handlebars', - pl: 'perl', - pm: 'perl', - hx: 'haxe', - pp: 'puppet', - ex: 'elixir', - exs: 'elixir', - ls: 'livescript', - erl: 'erlang', - twig: 'twig', - jl: 'julia', - elm: 'elm', - pure: 'purescript', - tpl: 'smarty', - styl: 'stylus', - re: 'reason', - rei: 'reason', - cmj: 'bucklescript', - merlin: 'merlin', - v: 'verilog', - vhd: 'verilog', - sv: 'verilog', - svh: 'verilog', - nb: 'mathematica', - wl: 'wolframlanguage', - wls: 'wolframlanguage', - njk: 'nunjucks', - nunjucks: 'nunjucks', - robot: 'robot', - sol: 'solidity', - au3: 'autoit', - haml: 'haml', - yang: 'yang', - tf: 'terraform', - tfvars: 'terraform', - tfstate: 'terraform', - applescript: 'applescript', - cake: 'cake', - feature: 'cucumber', - nim: 'nim', - nimble: 'nim', - apib: 'apiblueprint', - apiblueprint: 'apiblueprint', - tag: 'riot', - vfl: 'vfl', - kl: 'kl', - pcss: 'postcss', - sss: 'postcss', - todo: 'todo', - cfml: 'coldfusion', - cfc: 'coldfusion', - lucee: 'coldfusion', - cabal: 'cabal', - nix: 'nix', - slim: 'slim', - http: 'http', - rest: 'http', - rql: 'restql', - restql: 'restql', - kv: 'kivy', - graphcool: 'graphcool', - sbt: 'sbt', - cr: 'crystal', - cu: 'cuda', - cuh: 'cuda', - log: 'log', -}; - -const twoFileExtensionIcons = { - 'gradle.kts': 'gradle', - 'md.rendered': 'markdown', - 'markdown.rendered': 'markdown', - 'mdown.rendered': 'markdown', - 'mkd.rendered': 'markdown', - 'mkdn.rendered': 'markdown', - 'YAML-tmLanguage': 'yaml', - 'sln.dotsettings': 'settings', - 'sln.dotsettings.user': 'settings', - 'd.ts': 'typescript-def', - 'code-workplace': 'vscode', - '7z': 'zip', - 'c++': 'cpp', - 'vbox-prev': 'virtual', - 'js.map': 'javascript-map', - 'css.map': 'css-map', - 'spec.ts': 'test-ts', - 'test.ts': 'test-ts', - 'ts.snap': 'test-ts', - 'spec.tsx': 'test-jsx', - 'test.tsx': 'test-jsx', - 'tsx.snap': 'test-jsx', - 'spec.jsx': 'test-jsx', - 'test.jsx': 'test-jsx', - 'jsx.snap': 'test-jsx', - 'spec.js': 'test-js', - 'test.js': 'test-js', - 'js.snap': 'test-js', - 'routing.ts': 'angular-routing', - 'routing.js': 'angular-routing', - 'module.ts': 'angular', - 'module.js': 'angular', - 'ng-template': 'angular', - 'component.ts': 'angular-component', - 'component.js': 'angular-component', - 'guard.ts': 'angular-guard', - 'guard.js': 'angular-guard', - 'service.ts': 'angular-service', - 'service.js': 'angular-service', - 'pipe.ts': 'angular-pipe', - 'pipe.js': 'angular-pipe', - 'filter.js': 'angular-pipe', - 'directive.ts': 'angular-directive', - 'directive.js': 'angular-directive', - 'resolver.ts': 'angular-resolver', - 'resolver.js': 'angular-resolver', - 'tf.json': 'terraform', - 'blade.php': 'laravel', - 'inky.php': 'laravel', - 'reducer.ts': 'ngrx-reducer', - 'rootReducer.ts': 'ngrx-reducer', - 'state.ts': 'ngrx-state', - 'actions.ts': 'ngrx-actions', - 'effects.ts': 'ngrx-effects', - 'drone.yml': 'drone', -}; - -const fileNameIcons = { - '.jscsrc': 'json', - '.jshintrc': 'json', - 'tsconfig.json': 'json', - 'tslint.json': 'json', - 'composer.lock': 'json', - '.jsbeautifyrc': 'json', - '.esformatter': 'json', - 'cdp.pid': 'json', - '.htaccess': 'xml', - '.jshintignore': 'settings', - '.buildignore': 'settings', - makefile: 'settings', - '.mrconfig': 'settings', - '.yardopts': 'settings', - 'gradle.properties': 'gradle', - gradlew: 'gradle', - 'gradle-wrapper.properties': 'gradle', - COPYING: 'certificate', - 'COPYING.LESSER': 'certificate', - LICENSE: 'certificate', - LICENCE: 'certificate', - 'LICENSE.md': 'certificate', - 'LICENCE.md': 'certificate', - 'LICENSE.txt': 'certificate', - 'LICENCE.txt': 'certificate', - '.gitlab-license': 'certificate', - dockerfile: 'docker', - 'docker-compose.yml': 'docker', - '.mailmap': 'email', - '.gitignore': 'git', - '.gitconfig': 'git', - '.gitattributes': 'git', - '.gitmodules': 'git', - '.gitkeep': 'git', - 'git-history': 'git', - '.Rhistory': 'r', - 'cmakelists.txt': 'cmake', - 'cmakecache.txt': 'cmake', - 'angular-cli.json': 'angular', - '.angular-cli.json': 'angular', - '.vfl': 'vfl', - '.kl': 'kl', - 'postcss.config.js': 'postcss', - '.postcssrc.js': 'postcss', - 'project.graphcool': 'graphcool', - 'webpack.js': 'webpack', - 'webpack.ts': 'webpack', - 'webpack.base.js': 'webpack', - 'webpack.base.ts': 'webpack', - 'webpack.config.js': 'webpack', - 'webpack.config.ts': 'webpack', - 'webpack.common.js': 'webpack', - 'webpack.common.ts': 'webpack', - 'webpack.config.common.js': 'webpack', - 'webpack.config.common.ts': 'webpack', - 'webpack.config.common.babel.js': 'webpack', - 'webpack.config.common.babel.ts': 'webpack', - 'webpack.dev.js': 'webpack', - 'webpack.dev.ts': 'webpack', - 'webpack.config.dev.js': 'webpack', - 'webpack.config.dev.ts': 'webpack', - 'webpack.config.dev.babel.js': 'webpack', - 'webpack.config.dev.babel.ts': 'webpack', - 'webpack.prod.js': 'webpack', - 'webpack.prod.ts': 'webpack', - 'webpack.server.js': 'webpack', - 'webpack.server.ts': 'webpack', - 'webpack.client.js': 'webpack', - 'webpack.client.ts': 'webpack', - 'webpack.config.server.js': 'webpack', - 'webpack.config.server.ts': 'webpack', - 'webpack.config.client.js': 'webpack', - 'webpack.config.client.ts': 'webpack', - 'webpack.config.production.babel.js': 'webpack', - 'webpack.config.production.babel.ts': 'webpack', - 'webpack.config.prod.babel.js': 'webpack', - 'webpack.config.prod.babel.ts': 'webpack', - 'webpack.config.prod.js': 'webpack', - 'webpack.config.prod.ts': 'webpack', - 'webpack.config.production.js': 'webpack', - 'webpack.config.production.ts': 'webpack', - 'webpack.config.staging.js': 'webpack', - 'webpack.config.staging.ts': 'webpack', - 'webpack.config.babel.js': 'webpack', - 'webpack.config.babel.ts': 'webpack', - 'webpack.config.base.babel.js': 'webpack', - 'webpack.config.base.babel.ts': 'webpack', - 'webpack.config.base.js': 'webpack', - 'webpack.config.base.ts': 'webpack', - 'webpack.config.staging.babel.js': 'webpack', - 'webpack.config.staging.babel.ts': 'webpack', - 'webpack.config.coffee': 'webpack', - 'webpack.config.test.js': 'webpack', - 'webpack.config.test.ts': 'webpack', - 'webpack.config.vendor.js': 'webpack', - 'webpack.config.vendor.ts': 'webpack', - 'webpack.config.vendor.production.js': 'webpack', - 'webpack.config.vendor.production.ts': 'webpack', - 'webpack.test.js': 'webpack', - 'webpack.test.ts': 'webpack', - 'webpack.dist.js': 'webpack', - 'webpack.dist.ts': 'webpack', - 'webpackfile.js': 'webpack', - 'webpackfile.ts': 'webpack', - 'ionic.config.json': 'ionic', - '.io-config.json': 'ionic', - 'gulpfile.js': 'gulp', - 'gulpfile.ts': 'gulp', - 'gulpfile.babel.js': 'gulp', - 'package.json': 'nodejs', - 'package-lock.json': 'nodejs', - '.nvmrc': 'nodejs', - '.npmignore': 'npm', - '.npmrc': 'npm', - '.yarnrc': 'yarn', - '.yarnrc.yml': 'yarn', - 'yarn.lock': 'yarn', - '.yarnclean': 'yarn', - '.yarn-integrity': 'yarn', - 'yarn-error.log': 'yarn', - 'androidmanifest.xml': 'android', - '.env': 'tune', - '.env.example': 'tune', - '.babelrc': 'babel', - 'contributing.md': 'contributing', - 'contributing.md.rendered': 'contributing', - 'readme.md': 'readme', - 'readme.md.rendered': 'readme', - changelog: 'changelog', - 'changelog.md': 'changelog', - 'changelog.md.rendered': 'changelog', - CREDITS: 'credits', - 'credits.txt': 'credits', - 'credits.md': 'credits', - 'credits.md.rendered': 'credits', - '.flowconfig': 'flow', - 'favicon.png': 'favicon', - 'karma.conf.js': 'karma', - 'karma.conf.ts': 'karma', - 'karma.conf.coffee': 'karma', - 'karma.config.js': 'karma', - 'karma.config.ts': 'karma', - 'karma-main.js': 'karma', - 'karma-main.ts': 'karma', - '.bithoundrc': 'bithound', - 'appveyor.yml': 'appveyor', - '.travis.yml': 'travis', - 'protractor.conf.js': 'protractor', - 'protractor.conf.ts': 'protractor', - 'protractor.conf.coffee': 'protractor', - 'protractor.config.js': 'protractor', - 'protractor.config.ts': 'protractor', - 'fuse.js': 'fusebox', - procfile: 'heroku', - '.editorconfig': 'editorconfig', - '.gitlab-ci.yml': 'gitlab', - '.bowerrc': 'bower', - 'bower.json': 'bower', - '.eslintrc.js': 'eslint', - '.eslintrc.yaml': 'eslint', - '.eslintrc.yml': 'eslint', - '.eslintrc.json': 'eslint', - '.eslintrc': 'eslint', - '.eslintignore': 'eslint', - 'code_of_conduct.md': 'conduct', - 'code_of_conduct.md.rendered': 'conduct', - '.watchmanconfig': 'watchman', - 'aurelia.json': 'aurelia', - 'mocha.opts': 'mocha', - jenkinsfile: 'jenkins', - 'firebase.json': 'firebase', - '.firebaserc': 'firebase', - Rakefile: 'ruby', - 'rollup.config.js': 'rollup', - 'rollup.config.ts': 'rollup', - 'rollup-config.js': 'rollup', - 'rollup-config.ts': 'rollup', - 'rollup.config.prod.js': 'rollup', - 'rollup.config.prod.ts': 'rollup', - 'rollup.config.dev.js': 'rollup', - 'rollup.config.dev.ts': 'rollup', - 'rollup.config.prod.vendor.js': 'rollup', - 'rollup.config.prod.vendor.ts': 'rollup', - '.hhconfig': 'hack', - '.stylelintrc': 'stylelint', - 'stylelint.config.js': 'stylelint', - '.stylelintrc.json': 'stylelint', - '.stylelintrc.yaml': 'stylelint', - '.stylelintrc.yml': 'stylelint', - '.stylelintrc.js': 'stylelint', - '.stylelintignore': 'stylelint', - '.codeclimate.yml': 'code-climate', - '.prettierrc': 'prettier', - 'prettier.config.js': 'prettier', - '.prettierrc.js': 'prettier', - '.prettierrc.json': 'prettier', - '.prettierrc.yaml': 'prettier', - '.prettierrc.yml': 'prettier', - '.prettierignore': 'prettier', - 'nodemon.json': 'nodemon', - '.sonarrc': 'sonar', - browserslist: 'browserlist', - '.browserslistrc': 'browserlist', - '.snyk': 'snyk', - '.drone.yml': 'drone', -}; - -export default function getIconForFile(name) { - return ( - fileNameIcons[name] || - twoFileExtensionIcons[name ? name.split('.').slice(-2).join('.') : ''] || - fileExtensionIcons[name ? name.split('.').pop().toLowerCase() : ''] || - '' - ); -} diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 8a3a174f414..dfeb12d5cf5 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -43,11 +43,6 @@ export default { isBlob() { return this.file.type === 'blob'; }, - levelIndentation() { - return { - marginLeft: this.level ? `${this.level * 8}px` : null, - }; - }, fileClass() { return { 'file-open': this.isBlob && this.file.opened, @@ -144,7 +139,6 @@ export default { > <span ref="textOutput" - :style="levelIndentation" class="file-row-name" :title="file.name" data-qa-selector="file_name_content" @@ -198,6 +192,7 @@ export default { line-height: 16px; text-overflow: ellipsis; white-space: nowrap; + margin-left: calc(var(--level) * 16px); } .file-row-name .file-row-icon { diff --git a/app/assets/javascripts/vue_shared/components/file_tree.vue b/app/assets/javascripts/vue_shared/components/file_tree.vue index e7817b8f910..2e0cdbb12f9 100644 --- a/app/assets/javascripts/vue_shared/components/file_tree.vue +++ b/app/assets/javascripts/vue_shared/components/file_tree.vue @@ -20,11 +20,16 @@ export default { return this.file.isHeader ? 0 : this.level + 1; }, }, + methods: { + hasChildren(childFile) { + return childFile.tree?.length; + }, + }, }; </script> <template> - <div> + <div :style="{ '--level': level }"> <component :is="fileRowComponent" :level="level" @@ -39,6 +44,8 @@ export default { :file-row-component="fileRowComponent" :level="childFilesLevel" :file="childFile" + :class="{ 'tree-list-parent': hasChildren(childFile) }" + class="gl-relative" v-bind="$attrs" v-on="$listeners" /> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js index 993b4c11c0e..5b98af8c732 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js @@ -50,6 +50,7 @@ export const TOKEN_TITLE_ASSIGNEE = s__('SearchToken|Assignee'); export const TOKEN_TITLE_AUTHOR = __('Author'); export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential'); export const TOKEN_TITLE_CONTACT = s__('Crm|Contact'); +export const TOKEN_TITLE_GROUP = __('Group'); export const TOKEN_TITLE_LABEL = __('Label'); export const TOKEN_TITLE_MILESTONE = __('Milestone'); export const TOKEN_TITLE_MY_REACTION = __('My-Reaction'); @@ -67,6 +68,7 @@ export const TOKEN_TYPE_ASSIGNEE = 'assignee'; export const TOKEN_TYPE_AUTHOR = 'author'; export const TOKEN_TYPE_CONFIDENTIAL = 'confidential'; export const TOKEN_TYPE_CONTACT = 'contact'; +export const TOKEN_TYPE_GROUP = 'group'; export const TOKEN_TYPE_EPIC = 'epic'; // As health status gets reused between issue lists and boards // this is in the shared constants. Until we have not decoupled the EE filtered search bar @@ -85,5 +87,4 @@ export const TOKEN_TYPE_STATUS = 'status'; export const TOKEN_TYPE_TARGET_BRANCH = 'target-branch'; export const TOKEN_TYPE_TYPE = 'type'; export const TOKEN_TYPE_WEIGHT = 'weight'; - export const TOKEN_TYPE_SEARCH_WITHIN = 'in'; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue index e0fa06c159e..c8aeac75645 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue @@ -2,6 +2,7 @@ import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import { ITEM_TYPE } from '~/groups/constants'; +import { TYPENAME_CRM_CONTACT } from '~/graphql_shared/constants'; import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; import { createAlert } from '~/flash'; import { isPositiveInteger } from '~/lib/utils/number_utils'; @@ -93,7 +94,7 @@ export default { return `${getIdFromGraphQLId(contact.id)}`; }, formatContactGraphQLId(id) { - return convertToGraphQLId('CustomerRelations::Contact', id); + return convertToGraphQLId(TYPENAME_CRM_CONTACT, id); }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue index 3f030c8698c..ff0571031b5 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue @@ -2,6 +2,7 @@ import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import { ITEM_TYPE } from '~/groups/constants'; +import { TYPENAME_CRM_ORGANIZATION } from '~/graphql_shared/constants'; import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; import { createAlert } from '~/flash'; import { isPositiveInteger } from '~/lib/utils/number_utils'; @@ -90,7 +91,7 @@ export default { return `${getIdFromGraphQLId(organization.id)}`; }, formatOrganizationGraphQLId(id) { - return convertToGraphQLId('CustomerRelations::Organization', id); + return convertToGraphQLId(TYPENAME_CRM_ORGANIZATION, id); }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue index 71c50ef292a..9449e071a0d 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue @@ -79,6 +79,9 @@ export default { // labels.json and /groups/:id/labels & /projects/:id/labels // return response differently. this.labels = Array.isArray(res) ? res : res.data; + if (this.config.fetchLatestLabels) { + this.fetchLatestLabels(searchTerm); + } }) .catch(() => createAlert({ @@ -89,6 +92,21 @@ export default { this.loading = false; }); }, + fetchLatestLabels(searchTerm) { + this.config + .fetchLatestLabels(searchTerm) + .then((res) => { + // We'd want to avoid doing this check but + // labels.json and /groups/:id/labels & /projects/:id/labels + // return response differently. + this.labels = Array.isArray(res) ? res : res.data; + }) + .catch(() => + createAlert({ + message: __('There was a problem fetching latest labels.'), + }), + ); + }, }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/group_select/constants.js b/app/assets/javascripts/vue_shared/components/group_select/constants.js deleted file mode 100644 index 06537d682fe..00000000000 --- a/app/assets/javascripts/vue_shared/components/group_select/constants.js +++ /dev/null @@ -1,7 +0,0 @@ -import { __ } from '~/locale'; - -export const TOGGLE_TEXT = __('Search for a group'); -export const RESET_LABEL = __('Reset'); -export const FETCH_GROUPS_ERROR = __('Unable to fetch groups. Reload the page to try again.'); -export const FETCH_GROUP_ERROR = __('Unable to fetch group. Reload the page to try again.'); -export const QUERY_TOO_SHORT_MESSAGE = __('Enter at least three characters to search.'); diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index 8e459cc21ac..28baabbdb81 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -4,7 +4,7 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { glEmojiTag } from '~/emoji'; import { __, sprintf } from '~/locale'; -import CiIconBadge from './ci_badge_link.vue'; +import CiBadgeLink from './ci_badge_link.vue'; import TimeagoTooltip from './time_ago_tooltip.vue'; /** @@ -16,7 +16,7 @@ import TimeagoTooltip from './time_ago_tooltip.vue'; */ export default { components: { - CiIconBadge, + CiBadgeLink, TimeagoTooltip, GlButton, GlAvatarLink, @@ -120,7 +120,7 @@ export default { data-testid="ci-header-content" > <section class="header-main-content gl-mr-3"> - <ci-icon-badge :status="status" /> + <ci-badge-link class="gl-mr-3" :status="status" /> <strong data-testid="ci-header-item-text">{{ item }}</strong> diff --git a/app/assets/javascripts/vue_shared/components/incubation/incubation_alert.vue b/app/assets/javascripts/vue_shared/components/incubation/incubation_alert.vue new file mode 100644 index 00000000000..b704cec2475 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/incubation/incubation_alert.vue @@ -0,0 +1,61 @@ +<script> +import { GlAlert, GlLink } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; + +export default { + name: 'IncubationAlert', + components: { GlAlert, GlLink }, + props: { + featureName: { + type: String, + required: true, + }, + linkToFeedbackIssue: { + type: String, + required: true, + }, + }, + data() { + return { + isAlertDismissed: false, + }; + }, + computed: { + shouldShowAlert() { + return !this.isAlertDismissed; + }, + titleLabel() { + return sprintf(this.$options.i18n.titleLabel, { featureName: this.featureName }); + }, + }, + methods: { + dismissAlert() { + this.isAlertDismissed = true; + }, + }, + i18n: { + titleLabel: s__('Incubation|%{featureName} is in incubating phase'), + contentLabel: s__( + 'Incubation|GitLab incubates features to explore new use cases. These features are updated regularly, and support is limited.', + ), + learnMoreLabel: s__('Incubation|Learn more about incubating features'), + feedbackLabel: s__('Incubation|Give feedback on this feature'), + }, +}; +</script> + +<template> + <gl-alert + v-if="shouldShowAlert" + :title="titleLabel" + variant="warning" + :primary-button-text="$options.i18n.feedbackLabel" + :primary-button-link="linkToFeedbackIssue" + @dismiss="dismissAlert" + > + {{ $options.i18n.contentLabel }} + <gl-link href="https://about.gitlab.com/handbook/engineering/incubation/" target="_blank">{{ + $options.i18n.learnMoreLabel + }}</gl-link> + </gl-alert> +</template> diff --git a/app/assets/javascripts/vue_shared/components/incubation/pagination.vue b/app/assets/javascripts/vue_shared/components/incubation/pagination.vue new file mode 100644 index 00000000000..b5afe92316a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/incubation/pagination.vue @@ -0,0 +1,62 @@ +<script> +import { GlKeysetPagination } from '@gitlab/ui'; +import { setUrlParams } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; + +export default { + name: 'KeysetPagination', + components: { + GlKeysetPagination, + }, + props: { + startCursor: { + type: String, + required: false, + default: '', + }, + endCursor: { + type: String, + required: false, + default: '', + }, + hasNextPage: { + type: Boolean, + required: true, + }, + hasPreviousPage: { + type: Boolean, + required: true, + }, + }, + computed: { + previousPageLink() { + return setUrlParams({ cursor: this.startCursor }); + }, + nextPageLink() { + return setUrlParams({ cursor: this.endCursor }); + }, + isPaginationVisible() { + return this.hasPreviousPage || this.hasNextPage; + }, + }, + i18n: { + previousPageButtonLabel: __('Prev'), + nextPageButtonLabel: __('Next'), + }, +}; +</script> + +<template> + <div v-if="isPaginationVisible" class="gl--flex-center"> + <gl-keyset-pagination + :start-cursor="startCursor" + :end-cursor="endCursor" + :has-previous-page="hasPreviousPage" + :has-next-page="hasNextPage" + :prev-text="$options.i18n.previousPageButtonLabel" + :next-text="$options.i18n.nextPageButtonLabel" + :prev-button-link="previousPageLink" + :next-button-link="nextPageLink" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js index d80c1ff8b0c..9a88ab44f3d 100644 --- a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js +++ b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js @@ -1,9 +1,10 @@ import { issuableTypes } from '~/boards/constants'; +import { TYPE_ISSUE } from '~/issues/constants'; import blockingIssuesQuery from './graphql/blocking_issues.query.graphql'; import blockingEpicsQuery from './graphql/blocking_epics.query.graphql'; export const blockingIssuablesQueries = { - [issuableTypes.issue]: { + [TYPE_ISSUE]: { query: blockingIssuesQuery, }, [issuableTypes.epic]: { diff --git a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue index 253aca8837d..f5b4870d59f 100644 --- a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue +++ b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue @@ -1,8 +1,9 @@ <script> import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui'; import { issuableTypes } from '~/boards/constants'; -import { TYPE_ISSUE, TYPE_EPIC } from '~/graphql_shared/constants'; +import { TYPENAME_ISSUE, TYPENAME_EPIC } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_ISSUE } from '~/issues/constants'; import { truncate } from '~/lib/utils/text_utility'; import { __, n__, s__, sprintf } from '~/locale'; import { blockingIssuablesQueries } from './constants'; @@ -10,16 +11,16 @@ import { blockingIssuablesQueries } from './constants'; export default { i18n: { issuableType: { - [issuableTypes.issue]: __('issue'), + [TYPE_ISSUE]: __('issue'), [issuableTypes.epic]: __('epic'), }, }, graphQLIdType: { - [issuableTypes.issue]: TYPE_ISSUE, - [issuableTypes.epic]: TYPE_EPIC, + [TYPE_ISSUE]: TYPENAME_ISSUE, + [issuableTypes.epic]: TYPENAME_EPIC, }, referenceFormatter: { - [issuableTypes.issue]: (r) => r.split('/')[1], + [TYPE_ISSUE]: (r) => r.split('/')[1], }, defaultDisplayLimit: 3, textTruncateWidth: 80, @@ -42,7 +43,7 @@ export default { type: String, required: true, validator(value) { - return [issuableTypes.issue, issuableTypes.epic].includes(value); + return [TYPE_ISSUE, issuableTypes.epic].includes(value); }, }, }, @@ -119,7 +120,7 @@ export default { ); }, blockIcon() { - return this.issuableType === issuableTypes.issue ? 'issue-block' : 'entity-blocked'; + return this.issuableType === TYPE_ISSUE ? 'issue-block' : 'entity-blocked'; }, glIconId() { return `blocked-icon-${this.uniqueId}`; diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 7b76fc3fc6d..6f4cddbdfa2 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -82,6 +82,11 @@ export default { required: false, default: true, }, + autocompleteDataSources: { + type: Object, + required: false, + default: () => ({}), + }, line: { type: Object, required: false, @@ -257,6 +262,7 @@ export default { contacts: this.enableAutocomplete, }, true, + this.autocompleteDataSources, ); }, beforeDestroy() { diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue index c53118b9f62..7e6b0e4a63b 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -41,33 +41,25 @@ export default { required: false, default: true, }, - formFieldId: { - type: String, - required: true, - }, - formFieldName: { - type: String, - required: true, - }, enablePreview: { type: Boolean, required: false, default: true, }, + autocompleteDataSources: { + type: Object, + required: false, + default: () => ({}), + }, enableAutocomplete: { type: Boolean, required: false, default: true, }, - formFieldPlaceholder: { - type: String, - required: false, - default: '', - }, - formFieldAriaLabel: { - type: String, - required: false, - default: '', + formFieldProps: { + type: Object, + required: true, + validator: (prop) => prop.id && prop.name, }, autofocus: { type: Boolean, @@ -152,6 +144,7 @@ export default { :textarea-value="value" :markdown-docs-path="markdownDocsPath" :quick-actions-docs-path="quickActionsDocsPath" + :autocomplete-data-sources="autocompleteDataSources" :uploads-path="uploadsPath" :enable-preview="enablePreview" show-content-editor-switcher @@ -160,16 +153,13 @@ export default { > <template #textarea> <textarea - :id="formFieldId" + v-bind="formFieldProps" ref="textarea" :value="value" - :name="formFieldName" class="note-textarea js-gfm-input js-autosize markdown-area" dir="auto" :data-supports-quick-actions="supportsQuickActions" data-qa-selector="markdown_editor_form_field" - :aria-label="formFieldAriaLabel" - :placeholder="formFieldPlaceholder" @input="updateMarkdownFromMarkdownField" @keydown="$emit('keydown', $event)" > @@ -189,9 +179,8 @@ export default { @enableMarkdownEditor="onEditingModeChange('markdownField')" /> <input - :id="formFieldId" + v-bind="formFieldProps" :value="value" - :name="formFieldName" data-qa-selector="markdown_editor_form_field" type="hidden" /> diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/constants.js b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/constants.js new file mode 100644 index 00000000000..e5dca170965 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/constants.js @@ -0,0 +1,26 @@ +import { __ } from '~/locale'; + +export const RESOURCE_TYPE_ISSUE = 'issue'; +export const RESOURCE_TYPE_MERGE_REQUEST = 'merge-request'; +export const RESOURCE_TYPE_MILESTONE = 'milestone'; + +export const RESOURCE_TYPES = [ + RESOURCE_TYPE_ISSUE, + RESOURCE_TYPE_MERGE_REQUEST, + RESOURCE_TYPE_MILESTONE, +]; + +export const RESOURCE_OPTIONS = { + [RESOURCE_TYPE_ISSUE]: { + path: 'issues/new', + label: __('issue'), + }, + [RESOURCE_TYPE_MERGE_REQUEST]: { + path: 'merge_requests/new', + label: __('merge request'), + }, + [RESOURCE_TYPE_MILESTONE]: { + path: 'milestones/new', + label: __('milestone'), + }, +}; diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_group_projects_with_merge_requests_enabled.query.graphql b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_group_projects_with_merge_requests_enabled.query.graphql new file mode 100644 index 00000000000..578914dbbaf --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_group_projects_with_merge_requests_enabled.query.graphql @@ -0,0 +1,18 @@ +query searchUserGroupProjectsWithMergeRequestsEnabled($fullPath: ID!, $search: String) { + group(fullPath: $fullPath) { + id + projects( + search: $search + withMergeRequestsEnabled: true + includeSubgroups: true + sort: ACTIVITY_DESC + ) { + nodes { + id + name + nameWithNamespace + webUrl + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_groups_and_projects.query.graphql b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_groups_and_projects.query.graphql new file mode 100644 index 00000000000..8fe92cf7c6c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_groups_and_projects.query.graphql @@ -0,0 +1,21 @@ +query searchUserGroupsAndProjects($username: String!, $search: String) { + projects(sort: "latest_activity_desc", membership: true) { + nodes { + id + name + nameWithNamespace + webUrl + } + } + + user(username: $username) { + id + groups(search: $search) { + nodes { + id + name + webUrl + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_issues_enabled.query.graphql b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_issues_enabled.query.graphql new file mode 100644 index 00000000000..a630c885d28 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_issues_enabled.query.graphql @@ -0,0 +1,15 @@ +query searchUserProjectsWithIssuesEnabled($search: String) { + projects( + search: $search + membership: true + withIssuesEnabled: true + sort: "latest_activity_desc" + ) { + nodes { + id + name + nameWithNamespace + webUrl + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_merge_requests_enabled.query.graphql b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_merge_requests_enabled.query.graphql new file mode 100644 index 00000000000..44ebf755728 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_merge_requests_enabled.query.graphql @@ -0,0 +1,15 @@ +query searchUserProjectsWithMergeRequestsEnabled($search: String) { + projects( + search: $search + membership: true + withMergeRequestsEnabled: true + sort: "latest_activity_desc" + ) { + nodes { + id + name + nameWithNamespace + webUrl + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown.js b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown.js new file mode 100644 index 00000000000..f3905dabedd --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown.js @@ -0,0 +1,46 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import NewResourceDropdown from './new_resource_dropdown.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export const initNewResourceDropdown = (props = {}) => { + const el = document.querySelector('.js-new-resource-dropdown'); + + if (!el) { + return false; + } + + const { groupId, fullPath, username } = el.dataset; + + return new Vue({ + el, + apolloProvider, + render(createElement) { + return createElement(NewResourceDropdown, { + props: { + withLocalStorage: true, + groupId, + queryVariables: { + ...(fullPath + ? { + fullPath, + } + : {}), + ...(username + ? { + username, + } + : {}), + }, + ...props, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue new file mode 100644 index 00000000000..b079181bd10 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue @@ -0,0 +1,208 @@ +<script> +import { + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlLoadingIcon, + GlSearchBoxByType, +} from '@gitlab/ui'; +import { createAlert } from '~/flash'; +import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility'; +import { __, sprintf } from '~/locale'; +import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; +import AccessorUtilities from '~/lib/utils/accessor'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import searchUserProjectsWithIssuesEnabled from './graphql/search_user_projects_with_issues_enabled.query.graphql'; +import { RESOURCE_TYPE_ISSUE, RESOURCE_TYPES, RESOURCE_OPTIONS } from './constants'; + +export default { + i18n: { + noMatchesFound: __('No matches found'), + toggleButtonLabel: __('Toggle project select'), + }, + components: { + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlLoadingIcon, + GlSearchBoxByType, + LocalStorageSync, + }, + props: { + resourceType: { + type: String, + required: false, + default: RESOURCE_TYPE_ISSUE, + validator: (value) => RESOURCE_TYPES.includes(value), + }, + query: { + type: Object, + required: false, + default: () => searchUserProjectsWithIssuesEnabled, + }, + groupId: { + type: String, + required: false, + default: '', + }, + queryVariables: { + type: Object, + required: false, + default: () => ({}), + }, + extractProjects: { + type: Function, + required: false, + default: (data) => data?.projects?.nodes, + }, + withLocalStorage: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + projects: [], + search: '', + selectedProject: {}, + shouldSkipQuery: true, + }; + }, + apollo: { + projects: { + query() { + return this.query; + }, + variables() { + return { + search: this.search, + ...this.queryVariables, + }; + }, + update(data) { + return this.extractProjects(data) || []; + }, + error(error) { + createAlert({ + message: __('An error occurred while loading projects.'), + captureError: true, + error, + }); + }, + skip() { + return this.shouldSkipQuery; + }, + debounce: DEBOUNCE_DELAY, + }, + }, + computed: { + localStorageKey() { + return `group-${this.groupId}-new-${this.resourceType}-recent-project`; + }, + resourceOptions() { + return RESOURCE_OPTIONS[this.resourceType]; + }, + defaultDropdownText() { + return sprintf(__('Select project to create %{type}'), { type: this.resourceOptions.label }); + }, + dropdownHref() { + return this.hasSelectedProject + ? joinPaths(this.selectedProject.webUrl, DASH_SCOPE, this.resourceOptions.path) + : undefined; + }, + dropdownText() { + return this.hasSelectedProject + ? sprintf(__('New %{type} in %{project}'), { + type: this.resourceOptions.label, + project: this.selectedProject.name, + }) + : this.defaultDropdownText; + }, + hasSelectedProject() { + return this.selectedProject.webUrl; + }, + showNoSearchResultsText() { + return !this.projects.length && this.search; + }, + canUseLocalStorage() { + return this.withLocalStorage && AccessorUtilities.canUseLocalStorage(); + }, + selectedProjectForLocalStorage() { + const { webUrl, name } = this.selectedProject; + + return { webUrl, name }; + }, + }, + methods: { + handleDropdownClick() { + if (!this.dropdownHref) { + this.$refs.dropdown.show(); + } + }, + handleDropdownShown() { + if (this.shouldSkipQuery) { + this.shouldSkipQuery = false; + } + this.$refs.search.focusInput(); + }, + selectProject(project) { + this.selectedProject = project; + }, + initFromLocalStorage(storedProject) { + // Historically, the selected project was saved with the URL as the `url` property, so we are + // falling back to that legacy property if `webUrl` is empty. This ensures that we support + // localStorage data that was persisted prior to this change. + let webUrl = storedProject.webUrl || storedProject.url; + + // The select2 implementation used to include the resource path in the local storage. We + // need to clean this up so that we can then re-build a fresh URL in the computed prop. + webUrl = webUrl.endsWith(this.resourceOptions.path) + ? webUrl.slice(0, webUrl.length - this.resourceOptions.path.length) + : webUrl; + const dashSuffix = `${DASH_SCOPE}/`; + webUrl = webUrl.endsWith(dashSuffix) + ? webUrl.slice(0, webUrl.length - dashSuffix.length) + : webUrl; + + this.selectedProject = { webUrl, name: storedProject.name }; + }, + }, +}; +</script> + +<template> + <local-storage-sync + :storage-key="localStorageKey" + :value="selectedProjectForLocalStorage" + @input="initFromLocalStorage" + > + <gl-dropdown + ref="dropdown" + right + split + :split-href="dropdownHref" + :text="dropdownText" + :toggle-text="$options.i18n.toggleButtonLabel" + variant="confirm" + data-testid="new-resource-dropdown" + @click="handleDropdownClick" + @shown="handleDropdownShown" + > + <gl-search-box-by-type ref="search" v-model.trim="search" /> + <gl-loading-icon v-if="$apollo.queries.projects.loading" /> + <template v-else> + <gl-dropdown-item + v-for="project of projects" + :key="project.id" + @click="selectProject(project)" + > + {{ project.nameWithNamespace || project.name }} + </gl-dropdown-item> + <gl-dropdown-text v-if="showNoSearchResultsText"> + {{ $options.i18n.noMatchesFound }} + </gl-dropdown-text> + </template> + </gl-dropdown> + </local-storage-sync> +</template> diff --git a/app/assets/javascripts/vue_shared/components/registry/list_item.vue b/app/assets/javascripts/vue_shared/components/registry/list_item.vue index 5516c9943b8..5d0ee6adffe 100644 --- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue +++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue @@ -33,6 +33,7 @@ export default { 'gl-border-t-transparent': !this.first && !this.selected, 'gl-border-t-gray-100': this.first && !this.selected, 'gl-border-b-gray-100': !this.selected, + 'gl-border-t-transparent!': this.selected && !this.first, 'gl-bg-blue-50 gl-border-blue-200': this.selected, }; }, @@ -126,10 +127,9 @@ export default { <slot name="right-action"></slot> </div> </div> - <div class="gl-display-flex"> + <div v-if="isDetailsShown" class="gl-display-flex"> <div class="gl-w-7"></div> <div - v-if="isDetailsShown" class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-bg-gray-10 gl-rounded-base gl-inset-border-1-gray-100 gl-mb-3" > <div diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue deleted file mode 100644 index e3e3b9abc3c..00000000000 --- a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue +++ /dev/null @@ -1,43 +0,0 @@ -<script> -import { GlButton, GlModalDirective } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import RunnerAwsDeploymentsModal from './runner_aws_deployments_modal.vue'; - -export default { - components: { - GlButton, - RunnerAwsDeploymentsModal, - }, - directives: { - GlModalDirective, - }, - modalId: 'runner-aws-deployments-modal', - i18n: { - buttonText: s__('Runners|Deploy GitLab Runner in AWS'), - }, - data() { - return { - opened: false, - }; - }, - methods: { - onClick() { - this.opened = true; - }, - }, -}; -</script> -<template> - <div> - <gl-button - v-gl-modal-directive="$options.modalId" - class="gl-mt-4" - data-testid="show-modal-button" - variant="confirm" - @click="onClick" - > - {{ $options.i18n.buttonText }} - </gl-button> - <runner-aws-deployments-modal v-if="opened" :modal-id="$options.modalId" /> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue deleted file mode 100644 index 08acde1aefc..00000000000 --- a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue +++ /dev/null @@ -1,29 +0,0 @@ -<script> -import { GlModal } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import RunnerAwsInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue'; - -export default { - components: { - GlModal, - RunnerAwsInstructions, - }, - props: { - modalId: { - type: String, - required: true, - }, - }, - methods: { - onClose() { - this.$refs.modal.close(); - }, - }, - i18n_title: s__('Runners|Deploy GitLab Runner in AWS'), -}; -</script> -<template> - <gl-modal ref="modal" :modal-id="modalId" :title="$options.i18n_title" hide-footer size="sm"> - <runner-aws-instructions @close="onClose" /> - </gl-modal> -</template> diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js index 3dbc5246c3d..b66c89d1372 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js @@ -4,6 +4,7 @@ export const REGISTRATION_TOKEN_PLACEHOLDER = '$REGISTRATION_TOKEN'; export const PLATFORM_DOCKER = 'docker'; export const PLATFORM_KUBERNETES = 'kubernetes'; +export const PLATFORM_AWS = 'aws'; export const AWS_README_URL = 'https://gitlab.com/guided-explorations/aws/gitlab-runner-autoscaling-aws-asg/-/blob/main/easybuttons.md'; diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue index cafebdfe5f4..8a234889e6f 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue @@ -2,6 +2,7 @@ import { GlButton, GlSprintf, + GlIcon, GlLink, GlFormRadioGroup, GlFormRadio, @@ -11,6 +12,7 @@ import { import Tracking from '~/tracking'; import { getBaseURL, objectToQuery, visitUrl } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; import { AWS_README_URL, AWS_CF_BASE_URL, @@ -22,13 +24,22 @@ export default { components: { GlButton, GlSprintf, + GlIcon, GlLink, GlFormRadioGroup, GlFormRadio, GlAccordion, GlAccordionItem, + ModalCopyButton, }, mixins: [Tracking.mixin()], + props: { + registrationToken: { + type: String, + required: false, + default: null, + }, + }, data() { return { selectedIndex: 0, @@ -65,16 +76,20 @@ export default { }, }, i18n: { - title: s__('Runners|Deploy GitLab Runner in AWS'), instructions: s__( - 'Runners|Select your preferred option here. In the next step, you can choose the capacity for your runner in the AWS CloudFormation console.', + 'Runners|Select your preferred runner, then choose the capacity for the runner in the AWS CloudFormation console.', ), chooseRunner: s__('Runners|Choose your preferred GitLab Runner'), dontSeeWhatYouAreLookingFor: s__( "Runners|Don't see what you are looking for? See the full list of options, including a fully customizable option %{linkStart}here%{linkEnd}.", ), + runnerRegistrationToken: s__('Runners|Runner Registration token'), + copyInstructions: s__('Runners|Copy registration token'), moreDetails: __('More Details'), lessDetails: __('Less Details'), + close: __('Close'), + deployRunnerInAws: s__('Runners|Deploy GitLab Runner in AWS'), + externalLink: __('(external link)'), }, readmeUrl: AWS_README_URL, easyButtons: AWS_EASY_BUTTONS, @@ -83,6 +98,7 @@ export default { <template> <div> <p>{{ $options.i18n.instructions }}</p> + <gl-form-radio-group v-model="selectedIndex" :label="$options.i18n.chooseRunner" label-sr-only> <gl-form-radio v-for="(easyButton, idx) in $options.easyButtons" @@ -113,10 +129,23 @@ export default { </template> </gl-sprintf> </p> + <template v-if="registrationToken"> + <h5 class="gl-mb-3">{{ $options.i18n.runnerRegistrationToken }}</h5> + <div class="gl-display-flex"> + <pre class="gl-bg-gray gl-flex-grow-1 gl-white-space-pre-line">{{ registrationToken }}</pre> + <modal-copy-button + :title="$options.i18n.copyInstructions" + :text="registrationToken" + css-classes="gl-align-self-start gl-ml-2 gl-mt-2" + category="tertiary" + /> + </div> + </template> <footer class="gl-display-flex gl-justify-content-end gl-pt-3 gl-gap-3"> - <gl-button @click="onClose()">{{ __('Close') }}</gl-button> + <gl-button @click="onClose()">{{ $options.i18n.close }}</gl-button> <gl-button variant="confirm" @click="onOk()"> - {{ s__('Runners|Deploy GitLab Runner in AWS') }} + {{ $options.i18n.deployRunnerInAws }} + <gl-icon name="external-link" :aria-label="$options.i18n.externalLink" /> </gl-button> </footer> </div> diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue index 729fe9c462c..22d9b88fa41 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue @@ -14,11 +14,12 @@ import { import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { __, s__ } from '~/locale'; import getRunnerPlatformsQuery from './graphql/get_runner_platforms.query.graphql'; -import { PLATFORM_DOCKER, PLATFORM_KUBERNETES } from './constants'; +import { PLATFORM_DOCKER, PLATFORM_KUBERNETES, PLATFORM_AWS } from './constants'; import RunnerCliInstructions from './instructions/runner_cli_instructions.vue'; import RunnerDockerInstructions from './instructions/runner_docker_instructions.vue'; import RunnerKubernetesInstructions from './instructions/runner_kubernetes_instructions.vue'; +import RunnerAwsInstructions from './instructions/runner_aws_instructions.vue'; export default { components: { @@ -104,6 +105,8 @@ export default { return RunnerDockerInstructions; case PLATFORM_KUBERNETES: return RunnerKubernetesInstructions; + case PLATFORM_AWS: + return RunnerAwsInstructions; default: return null; } diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue index 28a16cd846a..092e8ba6c15 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue @@ -1,64 +1,55 @@ <script> import { GlIntersectionObserver } from '@gitlab/ui'; -import LineHighlighter from '~/blob/line_highlighter'; -import ChunkLine from './chunk_line.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { getPageParamValue, getPageSearchString } from '~/blob/utils'; /* * We only highlight the chunk that is currently visible to the user. * By making use of the Intersection Observer API we can determine when a chunk becomes visible and highlight it accordingly. * - * Content that is not visible to the user (i.e. not highlighted) do not need to look nice, - * so by making text transparent and rendering raw (non-highlighted) text, - * the browser spends less resources on painting content that is not immediately relevant. - * - * Why use transparent text as opposed to hiding content entirely? - * 1. If content is hidden entirely, native find text (⌘ + F) won't work. - * 2. When URL contains line numbers, the browser needs to be able to jump to the correct line. + * Content that is not visible to the user (i.e. not highlighted) does not need to look nice, + * so by rendering raw (non-highlighted) text, the browser spends less resources on painting + * content that is not immediately relevant. + * Why use plaintext as opposed to hiding content entirely? + * If content is hidden entirely, native find text (⌘ + F) won't work. */ export default { components: { - ChunkLine, GlIntersectionObserver, }, + directives: { + SafeHtml, + }, + mixins: [glFeatureFlagMixin()], props: { - isFirstChunk: { + isHighlighted: { type: Boolean, - required: false, - default: false, + required: true, }, chunkIndex: { type: Number, required: false, default: 0, }, - isHighlighted: { - type: Boolean, + rawContent: { + type: String, required: true, }, - content: { + highlightedContent: { type: String, required: true, }, - startingFrom: { - type: Number, - required: false, - default: 0, - }, totalLines: { type: Number, required: false, default: 0, }, - totalChunks: { + startingFrom: { type: Number, required: false, default: 0, }, - language: { - type: String, - required: false, - default: null, - }, blamePath: { type: String, required: true, @@ -66,37 +57,37 @@ export default { }, data() { return { + hasAppeared: false, isLoading: true, }; }, computed: { + shouldHighlight() { + return Boolean(this.highlightedContent) && (this.hasAppeared || this.isHighlighted); + }, lines() { return this.content.split('\n'); }, + pageSearchString() { + if (!this.glFeatures.fileLineBlame) return ''; + const page = getPageParamValue(this.number); + return getPageSearchString(this.blamePath, page); + }, }, - created() { - if (this.isFirstChunk) { + if (this.chunkIndex === 0) { + // Display first chunk ASAP in order to improve perceived performance this.isLoading = false; return; } - window.requestIdleCallback(async () => { + window.requestIdleCallback(() => { this.isLoading = false; - const { hash } = this.$route; - if (hash && this.totalChunks > 0 && this.totalChunks === this.chunkIndex + 1) { - // when the last chunk is loaded scroll to the hash - await this.$nextTick(); - const lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); - lineHighlighter.highlightHash(hash); - } }); }, methods: { handleChunkAppear() { - if (!this.isHighlighted) { - this.$emit('appear', this.chunkIndex); - } + this.hasAppeared = true; }, calculateLineNumber(index) { return this.startingFrom + index + 1; @@ -106,28 +97,37 @@ export default { </script> <template> <gl-intersection-observer @appear="handleChunkAppear"> - <div v-if="isHighlighted"> - <chunk-line - v-for="(line, index) in lines" - :key="index" - :number="calculateLineNumber(index)" - :content="line" - :language="language" - :blame-path="blamePath" - /> - </div> - <div v-else-if="!isLoading" class="gl-display-flex gl-text-transparent"> - <div class="gl-display-flex gl-flex-direction-column content-visibility-auto"> - <span + <div class="gl-display-flex"> + <div v-if="shouldHighlight" class="gl-display-flex gl-flex-direction-column"> + <div v-for="(n, index) in totalLines" - v-once - :id="`L${calculateLineNumber(index)}`" :key="index" - data-testid="line-number" - v-text="calculateLineNumber(index)" - ></span> + data-testid="line-numbers" + class="gl-p-0! gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers" + > + <a + v-if="glFeatures.fileLineBlame" + class="gl-user-select-none gl-shadow-none! file-line-blame" + :href="`${blamePath}${pageSearchString}#L${calculateLineNumber(index)}`" + ></a> + <a + :id="`L${calculateLineNumber(index)}`" + class="gl-user-select-none gl-shadow-none! file-line-num" + :href="`#L${calculateLineNumber(index)}`" + :data-line-number="calculateLineNumber(index)" + > + {{ calculateLineNumber(index) }} + </a> + </div> </div> - <div v-once class="gl-white-space-pre-wrap!" data-testid="content">{{ content }}</div> + + <div v-else-if="!isLoading" class="line-numbers gl-p-0! gl-mr-3 gl-text-transparent"> + <!-- Placeholder for line numbers while content is not highlighted --> + </div> + + <pre + class="gl-m-0 gl-p-0! gl-w-full gl-overflow-visible! gl-border-none! code highlight gl-line-height-0" + ><code v-if="shouldHighlight" v-once v-safe-html="highlightedContent" data-testid="content"></code><code v-else-if="!isLoading" v-once class="line gl-white-space-pre-wrap! gl-ml-1" data-testid="content" v-text="rawContent"></code></pre> </div> </gl-intersection-observer> </template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_deprecated.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_deprecated.vue new file mode 100644 index 00000000000..28a16cd846a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_deprecated.vue @@ -0,0 +1,133 @@ +<script> +import { GlIntersectionObserver } from '@gitlab/ui'; +import LineHighlighter from '~/blob/line_highlighter'; +import ChunkLine from './chunk_line.vue'; + +/* + * We only highlight the chunk that is currently visible to the user. + * By making use of the Intersection Observer API we can determine when a chunk becomes visible and highlight it accordingly. + * + * Content that is not visible to the user (i.e. not highlighted) do not need to look nice, + * so by making text transparent and rendering raw (non-highlighted) text, + * the browser spends less resources on painting content that is not immediately relevant. + * + * Why use transparent text as opposed to hiding content entirely? + * 1. If content is hidden entirely, native find text (⌘ + F) won't work. + * 2. When URL contains line numbers, the browser needs to be able to jump to the correct line. + */ +export default { + components: { + ChunkLine, + GlIntersectionObserver, + }, + props: { + isFirstChunk: { + type: Boolean, + required: false, + default: false, + }, + chunkIndex: { + type: Number, + required: false, + default: 0, + }, + isHighlighted: { + type: Boolean, + required: true, + }, + content: { + type: String, + required: true, + }, + startingFrom: { + type: Number, + required: false, + default: 0, + }, + totalLines: { + type: Number, + required: false, + default: 0, + }, + totalChunks: { + type: Number, + required: false, + default: 0, + }, + language: { + type: String, + required: false, + default: null, + }, + blamePath: { + type: String, + required: true, + }, + }, + data() { + return { + isLoading: true, + }; + }, + computed: { + lines() { + return this.content.split('\n'); + }, + }, + + created() { + if (this.isFirstChunk) { + this.isLoading = false; + return; + } + + window.requestIdleCallback(async () => { + this.isLoading = false; + const { hash } = this.$route; + if (hash && this.totalChunks > 0 && this.totalChunks === this.chunkIndex + 1) { + // when the last chunk is loaded scroll to the hash + await this.$nextTick(); + const lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); + lineHighlighter.highlightHash(hash); + } + }); + }, + methods: { + handleChunkAppear() { + if (!this.isHighlighted) { + this.$emit('appear', this.chunkIndex); + } + }, + calculateLineNumber(index) { + return this.startingFrom + index + 1; + }, + }, +}; +</script> +<template> + <gl-intersection-observer @appear="handleChunkAppear"> + <div v-if="isHighlighted"> + <chunk-line + v-for="(line, index) in lines" + :key="index" + :number="calculateLineNumber(index)" + :content="line" + :language="language" + :blame-path="blamePath" + /> + </div> + <div v-else-if="!isLoading" class="gl-display-flex gl-text-transparent"> + <div class="gl-display-flex gl-flex-direction-column content-visibility-auto"> + <span + v-for="(n, index) in totalLines" + v-once + :id="`L${calculateLineNumber(index)}`" + :key="index" + data-testid="line-number" + v-text="calculateLineNumber(index)" + ></span> + </div> + <div v-once class="gl-white-space-pre-wrap!" data-testid="content">{{ content }}</div> + </div> + </gl-intersection-observer> +</template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js index f382ded90d7..15335ea6edc 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js @@ -120,6 +120,8 @@ export const EVENT_LABEL_FALLBACK = 'legacy_fallback'; export const LINES_PER_CHUNK = 70; +export const NEWLINE = '\n'; + export const BIDI_CHARS = [ '\u202A', // Left-to-Right Embedding (Try treating following text as left-to-right) '\u202B', // Right-to-Left Embedding (Try treating following text as right-to-left) diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue index efafa67a733..11708b6f1f6 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue @@ -1,192 +1,40 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; -import LineHighlighter from '~/blob/line_highlighter'; -import eventHub from '~/notes/event_hub'; -import languageLoader from '~/content_editor/services/highlight_js_language_loader'; -import addBlobLinksTracking from '~/blob/blob_links_tracking'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import Tracking from '~/tracking'; -import { - EVENT_ACTION, - EVENT_LABEL_VIEWER, - EVENT_LABEL_FALLBACK, - ROUGE_TO_HLJS_LANGUAGE_MAP, - LINES_PER_CHUNK, - LEGACY_FALLBACKS, -} from './constants'; +import addBlobLinksTracking from '~/blob/blob_links_tracking'; +import { EVENT_ACTION, EVENT_LABEL_VIEWER } from './constants'; import Chunk from './components/chunk.vue'; -import { registerPlugins } from './plugins/index'; -/* - * This component is optimized to handle source code with many lines of code by splitting source code into chunks of 70 lines of code, - * we highlight and display the 1st chunk (L1-70) to the user as quickly as possible. - * - * The rest of the lines (L71+) is rendered once the browser goes into an idle state (requestIdleCallback). - * Each chunk is self-contained, this ensures when for example the width of a container on line 1000 changes, - * it does not trigger a repaint on a parent element that wraps all 1000 lines. - */ export default { components: { - GlLoadingIcon, Chunk, }, + directives: { + SafeHtml, + }, mixins: [Tracking.mixin()], + inject: { + highlightWorker: { default: null }, + }, props: { blob: { type: Object, required: true, }, - }, - data() { - return { - languageDefinition: null, - content: this.blob.rawTextBlob, - language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language?.toLowerCase()], - hljs: null, - firstChunk: null, - chunks: {}, - isLoading: true, - isLineSelected: false, - lineHighlighter: null, - }; - }, - computed: { - splitContent() { - return this.content.split(/\r?\n/); - }, - lineNumbers() { - return this.splitContent.length; - }, - unsupportedLanguage() { - const supportedLanguages = Object.keys(languageLoader); - const unsupportedLanguage = - !supportedLanguages.includes(this.language) && - !supportedLanguages.includes(this.blob.language?.toLowerCase()); - - return LEGACY_FALLBACKS.includes(this.language) || unsupportedLanguage; - }, - totalChunks() { - return Object.keys(this.chunks).length; + chunks: { + type: Array, + required: false, + default: () => [], }, }, - async created() { + created() { + this.track(EVENT_ACTION, { label: EVENT_LABEL_VIEWER, property: this.blob.language }); addBlobLinksTracking(); - this.trackEvent(EVENT_LABEL_VIEWER); - - if (this.unsupportedLanguage) { - this.handleUnsupportedLanguage(); - return; - } - - this.generateFirstChunk(); - this.hljs = await this.loadHighlightJS(); - - if (this.language) { - this.languageDefinition = await this.loadLanguage(); - } - - // Highlight the first chunk as soon as highlight.js is available - this.highlightChunk(null, true); - - window.requestIdleCallback(async () => { - // Generate the remaining chunks once the browser idles to ensure the browser resources are spent on the most important things first - this.generateRemainingChunks(); - this.isLoading = false; - await this.$nextTick(); - this.lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); - }); - }, - methods: { - trackEvent(label) { - this.track(EVENT_ACTION, { label, property: this.blob.language }); - }, - handleUnsupportedLanguage() { - this.trackEvent(EVENT_LABEL_FALLBACK); - this.$emit('error'); - }, - generateFirstChunk() { - const lines = this.splitContent.splice(0, LINES_PER_CHUNK); - this.firstChunk = this.createChunk(lines); - }, - generateRemainingChunks() { - const result = {}; - for (let i = 0; i < this.splitContent.length; i += LINES_PER_CHUNK) { - const chunkIndex = Math.floor(i / LINES_PER_CHUNK); - const lines = this.splitContent.slice(i, i + LINES_PER_CHUNK); - result[chunkIndex] = this.createChunk(lines, i + LINES_PER_CHUNK); - } - - this.chunks = result; - }, - createChunk(lines, startingFrom = 0) { - return { - content: lines.join('\n'), - startingFrom, - totalLines: lines.length, - language: this.language, - isHighlighted: false, - }; - }, - highlightChunk(index, isFirstChunk) { - const chunk = isFirstChunk ? this.firstChunk : this.chunks[index]; - - if (chunk.isHighlighted) { - return; - } - - const { highlightedContent, language } = this.highlight(chunk.content, this.language); - - Object.assign(chunk, { language, content: highlightedContent, isHighlighted: true }); - - this.selectLine(); - - this.$nextTick(() => eventHub.$emit('showBlobInteractionZones', this.blob.path)); - }, - highlight(content, language) { - let detectedLanguage = language; - let highlightedContent; - if (this.hljs) { - registerPlugins(this.hljs, this.blob.fileType, this.content); - if (!detectedLanguage) { - const hljsHighlightAuto = this.hljs.highlightAuto(content); - highlightedContent = hljsHighlightAuto.value; - detectedLanguage = hljsHighlightAuto.language; - } else if (this.languageDefinition) { - highlightedContent = this.hljs.highlight(content, { language: this.language }).value; - } - } - - return { highlightedContent, language: detectedLanguage }; - }, - loadHighlightJS() { - // If no language can be mapped to highlight.js we load all common languages else we load only the core (smallest footprint) - return !this.language ? import('highlight.js/lib/common') : import('highlight.js/lib/core'); - }, - async loadLanguage() { - let languageDefinition; - - try { - languageDefinition = await languageLoader[this.language](); - this.hljs.registerLanguage(this.language, languageDefinition.default); - } catch (message) { - this.$emit('error', message); - } - - return languageDefinition; - }, - async selectLine() { - if (this.isLineSelected || !this.lineHighlighter) { - return; - } - - this.isLineSelected = true; - await this.$nextTick(); - this.lineHighlighter.highlightHash(this.$route.hash); - }, }, userColorScheme: window.gon.user_color_scheme, - currentlySelectedLine: null, }; </script> + <template> <div class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto" @@ -196,32 +44,15 @@ export default { data-qa-selector="blob_viewer_file_content" > <chunk - v-if="firstChunk" - :lines="firstChunk.lines" - :total-lines="firstChunk.totalLines" - :content="firstChunk.content" - :starting-from="firstChunk.startingFrom" - :is-highlighted="firstChunk.isHighlighted" - is-first-chunk - :language="firstChunk.language" - :blame-path="blob.blamePath" - /> - - <gl-loading-icon v-if="isLoading" size="sm" class="gl-my-5" /> - <chunk - v-for="(chunk, key, index) in chunks" - v-else - :key="key" - :lines="chunk.lines" - :content="chunk.content" + v-for="(chunk, _, index) in chunks" + :key="index" + :chunk-index="index" + :is-highlighted="Boolean(chunk.isHighlighted)" + :raw-content="chunk.rawContent" + :highlighted-content="chunk.highlightedContent" :total-lines="chunk.totalLines" :starting-from="chunk.startingFrom" - :is-highlighted="chunk.isHighlighted" - :chunk-index="index" - :language="chunk.language" :blame-path="blob.blamePath" - :total-chunks="totalChunks" - @appear="highlightChunk" /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_deprecated.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_deprecated.vue new file mode 100644 index 00000000000..26cf45c7570 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_deprecated.vue @@ -0,0 +1,227 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import LineHighlighter from '~/blob/line_highlighter'; +import eventHub from '~/notes/event_hub'; +import languageLoader from '~/content_editor/services/highlight_js_language_loader'; +import addBlobLinksTracking from '~/blob/blob_links_tracking'; +import Tracking from '~/tracking'; +import { + EVENT_ACTION, + EVENT_LABEL_VIEWER, + EVENT_LABEL_FALLBACK, + ROUGE_TO_HLJS_LANGUAGE_MAP, + LINES_PER_CHUNK, + LEGACY_FALLBACKS, +} from './constants'; +import Chunk from './components/chunk_deprecated.vue'; +import { registerPlugins } from './plugins/index'; + +/* + * This component is optimized to handle source code with many lines of code by splitting source code into chunks of 70 lines of code, + * we highlight and display the 1st chunk (L1-70) to the user as quickly as possible. + * + * The rest of the lines (L71+) is rendered once the browser goes into an idle state (requestIdleCallback). + * Each chunk is self-contained, this ensures when for example the width of a container on line 1000 changes, + * it does not trigger a repaint on a parent element that wraps all 1000 lines. + */ +export default { + components: { + GlLoadingIcon, + Chunk, + }, + mixins: [Tracking.mixin()], + props: { + blob: { + type: Object, + required: true, + }, + }, + data() { + return { + languageDefinition: null, + content: this.blob.rawTextBlob, + language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language?.toLowerCase()], + hljs: null, + firstChunk: null, + chunks: {}, + isLoading: true, + isLineSelected: false, + lineHighlighter: null, + }; + }, + computed: { + splitContent() { + return this.content.split(/\r?\n/); + }, + lineNumbers() { + return this.splitContent.length; + }, + unsupportedLanguage() { + const supportedLanguages = Object.keys(languageLoader); + const unsupportedLanguage = + !supportedLanguages.includes(this.language) && + !supportedLanguages.includes(this.blob.language?.toLowerCase()); + + return LEGACY_FALLBACKS.includes(this.language) || unsupportedLanguage; + }, + totalChunks() { + return Object.keys(this.chunks).length; + }, + }, + async created() { + addBlobLinksTracking(); + this.trackEvent(EVENT_LABEL_VIEWER); + + if (this.unsupportedLanguage) { + this.handleUnsupportedLanguage(); + return; + } + + this.generateFirstChunk(); + this.hljs = await this.loadHighlightJS(); + + if (this.language) { + this.languageDefinition = await this.loadLanguage(); + } + + // Highlight the first chunk as soon as highlight.js is available + this.highlightChunk(null, true); + + window.requestIdleCallback(async () => { + // Generate the remaining chunks once the browser idles to ensure the browser resources are spent on the most important things first + this.generateRemainingChunks(); + this.isLoading = false; + await this.$nextTick(); + this.lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); + }); + }, + methods: { + trackEvent(label) { + this.track(EVENT_ACTION, { label, property: this.blob.language }); + }, + handleUnsupportedLanguage() { + this.trackEvent(EVENT_LABEL_FALLBACK); + this.$emit('error'); + }, + generateFirstChunk() { + const lines = this.splitContent.splice(0, LINES_PER_CHUNK); + this.firstChunk = this.createChunk(lines); + }, + generateRemainingChunks() { + const result = {}; + for (let i = 0; i < this.splitContent.length; i += LINES_PER_CHUNK) { + const chunkIndex = Math.floor(i / LINES_PER_CHUNK); + const lines = this.splitContent.slice(i, i + LINES_PER_CHUNK); + result[chunkIndex] = this.createChunk(lines, i + LINES_PER_CHUNK); + } + + this.chunks = result; + }, + createChunk(lines, startingFrom = 0) { + return { + content: lines.join('\n'), + startingFrom, + totalLines: lines.length, + language: this.language, + isHighlighted: false, + }; + }, + highlightChunk(index, isFirstChunk) { + const chunk = isFirstChunk ? this.firstChunk : this.chunks[index]; + + if (chunk.isHighlighted) { + return; + } + + const { highlightedContent, language } = this.highlight(chunk.content, this.language); + + Object.assign(chunk, { language, content: highlightedContent, isHighlighted: true }); + + this.selectLine(); + + this.$nextTick(() => eventHub.$emit('showBlobInteractionZones', this.blob.path)); + }, + highlight(content, language) { + let detectedLanguage = language; + let highlightedContent; + if (this.hljs) { + registerPlugins(this.hljs, this.blob.fileType, this.content); + if (!detectedLanguage) { + const hljsHighlightAuto = this.hljs.highlightAuto(content); + highlightedContent = hljsHighlightAuto.value; + detectedLanguage = hljsHighlightAuto.language; + } else if (this.languageDefinition) { + highlightedContent = this.hljs.highlight(content, { language: this.language }).value; + } + } + + return { highlightedContent, language: detectedLanguage }; + }, + loadHighlightJS() { + // If no language can be mapped to highlight.js we load all common languages else we load only the core (smallest footprint) + return !this.language ? import('highlight.js/lib/common') : import('highlight.js/lib/core'); + }, + async loadLanguage() { + let languageDefinition; + + try { + languageDefinition = await languageLoader[this.language](); + this.hljs.registerLanguage(this.language, languageDefinition.default); + } catch (message) { + this.$emit('error', message); + } + + return languageDefinition; + }, + async selectLine() { + if (this.isLineSelected || !this.lineHighlighter) { + return; + } + + this.isLineSelected = true; + await this.$nextTick(); + this.lineHighlighter.highlightHash(this.$route.hash); + }, + }, + userColorScheme: window.gon.user_color_scheme, + currentlySelectedLine: null, +}; +</script> +<template> + <div + class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto" + :class="$options.userColorScheme" + data-type="simple" + :data-path="blob.path" + data-qa-selector="blob_viewer_file_content" + > + <chunk + v-if="firstChunk" + :lines="firstChunk.lines" + :total-lines="firstChunk.totalLines" + :content="firstChunk.content" + :starting-from="firstChunk.startingFrom" + :is-highlighted="firstChunk.isHighlighted" + is-first-chunk + :language="firstChunk.language" + :blame-path="blob.blamePath" + /> + + <gl-loading-icon v-if="isLoading" size="sm" class="gl-my-5" /> + <chunk + v-for="(chunk, key, index) in chunks" + v-else + :key="key" + :lines="chunk.lines" + :content="chunk.content" + :total-lines="chunk.totalLines" + :starting-from="chunk.startingFrom" + :is-highlighted="chunk.isHighlighted" + :chunk-index="index" + :language="chunk.language" + :blame-path="blob.blamePath" + :total-chunks="totalChunks" + @appear="highlightChunk" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js index 0da57f9e6fa..142c135e9c1 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js @@ -1,15 +1,47 @@ -import hljs from 'highlight.js/lib/core'; -import languageLoader from '~/content_editor/services/highlight_js_language_loader'; +import hljs from 'highlight.js'; import { registerPlugins } from '../plugins/index'; +import { LINES_PER_CHUNK, NEWLINE, ROUGE_TO_HLJS_LANGUAGE_MAP } from '../constants'; -const initHighlightJs = async (fileType, content, language) => { - const languageDefinition = await languageLoader[language](); - +const initHighlightJs = (fileType, content) => { registerPlugins(hljs, fileType, content); - hljs.registerLanguage(language, languageDefinition.default); }; -export const highlight = (fileType, content, language) => { - initHighlightJs(fileType, content, language); - return hljs.highlight(content, { language }).value; +const splitByLineBreaks = (content = '') => content.split(/\r?\n/); + +const createChunk = (language, rawChunkLines, highlightedChunkLines = [], startingFrom = 0) => ({ + highlightedContent: highlightedChunkLines.join(NEWLINE), + rawContent: rawChunkLines.join(NEWLINE), + totalLines: rawChunkLines.length, + startingFrom, + language, +}); + +const splitIntoChunks = (language, rawContent, highlightedContent) => { + const result = []; + const splitRawContent = splitByLineBreaks(rawContent); + const splitHighlightedContent = splitByLineBreaks(highlightedContent); + + for (let i = 0; i < splitRawContent.length; i += LINES_PER_CHUNK) { + const chunkIndex = Math.floor(i / LINES_PER_CHUNK); + const highlightedChunk = splitHighlightedContent.slice(i, i + LINES_PER_CHUNK); + const rawChunk = splitRawContent.slice(i, i + LINES_PER_CHUNK); + result[chunkIndex] = createChunk(language, rawChunk, highlightedChunk, i); + } + + return result; +}; + +const highlight = (fileType, rawContent, lang) => { + const language = ROUGE_TO_HLJS_LANGUAGE_MAP[lang.toLowerCase()]; + let result; + + if (language) { + initHighlightJs(fileType, rawContent, language); + const highlightedContent = hljs.highlight(rawContent, { language }).value; + result = splitIntoChunks(language, rawContent, highlightedContent); + } + + return result; }; + +export { highlight, splitIntoChunks }; diff --git a/app/assets/javascripts/vue_shared/components/url_sync.vue b/app/assets/javascripts/vue_shared/components/url_sync.vue index bd5b7b77017..ad81c14d9e5 100644 --- a/app/assets/javascripts/vue_shared/components/url_sync.vue +++ b/app/assets/javascripts/vue_shared/components/url_sync.vue @@ -1,7 +1,9 @@ <script> -import { historyPushState } from '~/lib/utils/common_utils'; +import { historyPushState, historyReplaceState } from '~/lib/utils/common_utils'; import { mergeUrlParams, setUrlParams } from '~/lib/utils/url_utility'; +export const HISTORY_PUSH_UPDATE_METHOD = 'push'; +export const HISTORY_REPLACE_UPDATE_METHOD = 'replace'; export const URL_SET_PARAMS_STRATEGY = 'set'; export const URL_MERGE_PARAMS_STRATEGY = 'merge'; @@ -24,6 +26,13 @@ export default { default: URL_MERGE_PARAMS_STRATEGY, validator: (value) => [URL_MERGE_PARAMS_STRATEGY, URL_SET_PARAMS_STRATEGY].includes(value), }, + historyUpdateMethod: { + type: String, + required: false, + default: HISTORY_PUSH_UPDATE_METHOD, + validator: (value) => + [HISTORY_PUSH_UPDATE_METHOD, HISTORY_REPLACE_UPDATE_METHOD].includes(value), + }, }, watch: { query: { @@ -40,9 +49,14 @@ export default { updateQuery(newQuery) { const url = this.urlParamsUpdateStrategy === URL_SET_PARAMS_STRATEGY - ? setUrlParams(this.query, window.location.href, true) + ? setUrlParams(this.query, window.location.href, true, true, true) : mergeUrlParams(newQuery, window.location.href, { spreadArrays: true }); - historyPushState(url); + + if (this.historyUpdateMethod === HISTORY_PUSH_UPDATE_METHOD) { + historyPushState(url); + } else { + historyReplaceState(url); + } }, }, render() { diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue index 231f5ff3d1f..167db3ce1f2 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue @@ -74,8 +74,8 @@ export default { <user-avatar-link v-for="item in visibleItems" :key="item.id" - :link-href="item.web_url" - :img-src="item.avatar_url" + :link-href="item.web_url || item.webUrl" + :img-src="item.avatar_url || item.avatarUrl" :img-alt="item.name" :tooltip-text="item.name" :img-size="imgSize" diff --git a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue index 86a99b8f0ed..edcfabe7da3 100644 --- a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue +++ b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue @@ -2,18 +2,19 @@ import { debounce } from 'lodash'; import { GlDropdown, - GlDropdownForm, GlDropdownDivider, + GlDropdownForm, GlDropdownItem, - GlSearchBoxByType, GlLoadingIcon, + GlSearchBoxByType, GlTooltipDirective, } from '@gitlab/ui'; import { __ } from '~/locale'; import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue'; -import { IssuableType } from '~/issues/constants'; +import { IssuableType, TYPE_ISSUE } from '~/issues/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { participantsQueries, userSearchQueries } from '~/sidebar/constants'; +import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; export default { @@ -47,7 +48,8 @@ export default { }, iid: { type: String, - required: true, + required: false, + default: null, }, value: { type: Array, @@ -65,7 +67,7 @@ export default { issuableType: { type: String, required: false, - default: IssuableType.Issue, + default: TYPE_ISSUE, }, isEditing: { type: Boolean, @@ -160,20 +162,17 @@ export default { } return { ...variables, - mergeRequestId: convertToGraphQLId('MergeRequest', this.issuableId), + mergeRequestId: convertToGraphQLId(TYPENAME_MERGE_REQUEST, this.issuableId), }; }, isLoading() { return this.$apollo.queries.searchUsers.loading || this.$apollo.queries.participants.loading; }, users() { - if (!this.participants) { - return []; - } - - const filteredParticipants = this.participants.filter( - (user) => user.name.includes(this.search) || user.username.includes(this.search), - ); + const filteredParticipants = + this.participants?.filter( + (user) => user.name.includes(this.search) || user.username.includes(this.search), + ) || []; // TODO this de-duplication is temporary (BE fix required) // https://gitlab.com/gitlab-org/gitlab/-/issues/327822 @@ -254,6 +253,10 @@ export default { this.$emit('input', selected); } }, + unassign() { + this.$emit('input', []); + this.$refs.dropdown.hide(); + }, unselect(name) { const selected = this.value.filter((user) => user.username !== name); this.$emit('input', selected); @@ -323,7 +326,7 @@ export default { :is-checked="selectedIsEmpty" is-check-centered data-testid="unassign" - @click.native.capture.stop="$emit('input', [])" + @click.native.capture.stop="unassign" > <span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" class="gl-font-weight-bold">{{ $options.i18n.unassigned diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue index 98630512308..28bec63b244 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -423,6 +423,7 @@ export default { target="_blank" :href="webIdeUrl" block + @click="dismissCalloutOnActionClicked(dismiss)" > {{ __('Try it out now') }} </gl-link> diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js index c93dd95a886..fd151751372 100644 --- a/app/assets/javascripts/vue_shared/constants.js +++ b/app/assets/javascripts/vue_shared/constants.js @@ -1,5 +1,5 @@ import { __, n__, sprintf } from '~/locale'; -import { IssuableType, WorkspaceType } from '~/issues/constants'; +import { TYPE_ISSUE, WorkspaceType } from '~/issues/constants'; const INTERVALS = { minute: 'minute', @@ -88,9 +88,9 @@ export const confidentialityInfoText = (workspaceType, issuableType) => ), { workspaceType: workspaceType === WorkspaceType.project ? __('project') : __('group'), - issuableType: issuableType === IssuableType.Issue ? __('issue') : __('epic'), + issuableType: issuableType === TYPE_ISSUE ? __('issue') : __('epic'), permissions: - issuableType === IssuableType.Issue + issuableType === TYPE_ISSUE ? __('at least the Reporter role, the author, and assignees') : __('at least the Reporter role'), }, diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue index 5eb3da3c62e..d78530239a5 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue @@ -173,6 +173,7 @@ export default { :can-edit="enableEdit" :task-list-update-path="taskListUpdatePath" /> + <slot name="secondary-content"></slot> <small v-if="isUpdated" class="edited-text gl-font-sm!"> {{ __('Edited') }} <time-ago-tooltip :time="issuable.updatedAt" tooltip-placement="bottom" /> diff --git a/app/assets/javascripts/vue_shared/security_reports/components/constants.js b/app/assets/javascripts/vue_shared/security_reports/components/constants.js index 9b1cbfe218b..6fe98764fcd 100644 --- a/app/assets/javascripts/vue_shared/security_reports/components/constants.js +++ b/app/assets/javascripts/vue_shared/security_reports/components/constants.js @@ -1,8 +1,8 @@ export const SEVERITY_CLASS_NAME_MAP = { - critical: 'text-danger-800', - high: 'text-danger-600', - medium: 'text-warning-400', - low: 'text-warning-200', - info: 'text-primary-400', - unknown: 'text-secondary-400', + critical: 'gl-text-red-800', + high: 'gl-text-red-600', + medium: 'gl-text-orange-400', + low: 'gl-text-orange-200', + info: 'gl-text-blue-400', + unknown: 'gl-text-gray-400', }; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/utils.js b/app/assets/javascripts/vue_shared/security_reports/store/utils.js index f3cb5fc16f0..f620bad8dba 100644 --- a/app/assets/javascripts/vue_shared/security_reports/store/utils.js +++ b/app/assets/javascripts/vue_shared/security_reports/store/utils.js @@ -24,42 +24,37 @@ export const fetchDiffData = (state, endpoint, category) => { /** * Returns given vulnerability enriched with the corresponding * feedback (`dismissal` or `issue` type) - * @param {Object} vulnerability - * @param {Array} feedback + * @param {Object} vulnerabilityObject + * @param {Array} feedbackList */ -export const enrichVulnerabilityWithFeedback = (vulnerability, feedback = []) => - feedback +export const enrichVulnerabilityWithFeedback = (vulnerabilityObject, feedbackList = []) => { + const vulnerability = { ...vulnerabilityObject }; + // Some records may have a null `uuid`, we need to fallback to using `project_fingerprint` in those cases. Once all entries have been fixed, we can remove the fallback. + // related epic: https://gitlab.com/groups/gitlab-org/-/epics/2791 + feedbackList .filter((fb) => - // Some records still have a `finding_uuid` with null, we need to fallback to using `project_fingerprint` in those cases. Once all entries have been fixed, we can remove the fallback. - // related epic: https://gitlab.com/groups/gitlab-org/-/epics/2791 - fb.finding_uuid !== null - ? fb.finding_uuid === vulnerability.finding_uuid + fb.finding_uuid + ? fb.finding_uuid === vulnerability.uuid : fb.project_fingerprint === vulnerability.project_fingerprint, ) - .reduce((vuln, fb) => { - if (fb.feedback_type === FEEDBACK_TYPE_DISMISSAL) { - return { - ...vuln, - isDismissed: true, - dismissalFeedback: fb, - }; + .forEach((feedback) => { + if (feedback.feedback_type === FEEDBACK_TYPE_DISMISSAL) { + vulnerability.isDismissed = true; + vulnerability.dismissalFeedback = feedback; + } else if (feedback.feedback_type === FEEDBACK_TYPE_ISSUE && feedback.issue_iid) { + vulnerability.hasIssue = true; + vulnerability.issue_feedback = feedback; + } else if ( + feedback.feedback_type === FEEDBACK_TYPE_MERGE_REQUEST && + feedback.merge_request_iid + ) { + vulnerability.hasMergeRequest = true; + vulnerability.merge_request_feedback = feedback; } - if (fb.feedback_type === FEEDBACK_TYPE_ISSUE && fb.issue_iid) { - return { - ...vuln, - hasIssue: true, - issue_feedback: fb, - }; - } - if (fb.feedback_type === FEEDBACK_TYPE_MERGE_REQUEST && fb.merge_request_iid) { - return { - ...vuln, - hasMergeRequest: true, - merge_request_feedback: fb, - }; - } - return vuln; - }, vulnerability); + }); + + return vulnerability; +}; /** * Generates the added, fixed, and existing vulnerabilities from the API report. |