diff options
Diffstat (limited to 'app/assets/javascripts')
865 files changed, 11324 insertions, 6557 deletions
diff --git a/app/assets/javascripts/access_tokens/components/token.vue b/app/assets/javascripts/access_tokens/components/token.vue new file mode 100644 index 00000000000..3954e541fe0 --- /dev/null +++ b/app/assets/javascripts/access_tokens/components/token.vue @@ -0,0 +1,55 @@ +<script> +import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; + +export default { + components: { InputCopyToggleVisibility }, + props: { + token: { + type: String, + required: true, + }, + inputId: { + type: String, + required: true, + }, + inputLabel: { + type: String, + required: true, + }, + copyButtonTitle: { + type: String, + required: true, + }, + }, + computed: { + formInputGroupProps() { + return { id: this.inputId }; + }, + }, +}; +</script> + +<template> + <div class="row"> + <div class="col-lg-12"> + <hr /> + </div> + <div class="col-lg-4"> + <h4 class="gl-mt-0"><slot name="title"></slot></h4> + <slot name="description"></slot> + </div> + <div class="col-lg-8"> + <input-copy-toggle-visibility + :label="inputLabel" + :label-for="inputId" + :form-input-group-props="formInputGroupProps" + :value="token" + :copy-button-title="copyButtonTitle" + > + <template #description> + <slot name="input-description"></slot> + </template> + </input-copy-toggle-visibility> + </div> + </div> +</template> diff --git a/app/assets/javascripts/access_tokens/components/tokens_app.vue b/app/assets/javascripts/access_tokens/components/tokens_app.vue new file mode 100644 index 00000000000..755991f64e0 --- /dev/null +++ b/app/assets/javascripts/access_tokens/components/tokens_app.vue @@ -0,0 +1,111 @@ +<script> +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { pickBy } from 'lodash'; + +import { s__ } from '~/locale'; + +import { FEED_TOKEN, INCOMING_EMAIL_TOKEN, STATIC_OBJECT_TOKEN } from '../constants'; +import Token from './token.vue'; + +export default { + i18n: { + canNotAccessOtherData: s__('AccessTokens|It cannot be used to access any other data.'), + [FEED_TOKEN]: { + label: s__('AccessTokens|Feed token'), + copyButtonTitle: s__('AccessTokens|Copy feed token'), + description: s__( + 'AccessTokens|Your feed token authenticates you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar. It is visible in those feed URLs.', + ), + inputDescription: s__( + 'AccessTokens|Keep this token secret. Anyone who has it can read activity and issue RSS feeds or your calendar feed as if they were you. If that happens, %{linkStart}reset this token%{linkEnd}.', + ), + resetConfirmMessage: s__( + 'AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working.', + ), + }, + [INCOMING_EMAIL_TOKEN]: { + label: s__('AccessTokens|Incoming email token'), + copyButtonTitle: s__('AccessTokens|Copy incoming email token'), + description: s__( + 'AccessTokens|Your incoming email token authenticates you when you create a new issue by email, and is included in your personal project-specific email addresses.', + ), + inputDescription: s__( + 'AccessTokens|Keep this token secret. Anyone who has it can create issues as if they were you. If that happens, %{linkStart}reset this token%{linkEnd}.', + ), + resetConfirmMessage: s__( + 'AccessTokens|Are you sure? Any issue email addresses currently in use will stop working.', + ), + }, + [STATIC_OBJECT_TOKEN]: { + label: s__('AccessTokens|Static object token'), + copyButtonTitle: s__('AccessTokens|Copy static object token'), + description: s__( + 'AccessTokens|Your static object token authenticates you when repository static objects (such as archives or blobs) are served from an external storage.', + ), + inputDescription: s__( + 'AccessTokens|Keep this token secret. Anyone who has it can access repository static objects as if they were you. If that ever happens, %{linkStart}reset this token%{linkEnd}.', + ), + resetConfirmMessage: s__('AccessTokens|Are you sure?'), + }, + }, + htmlAttributes: { + [FEED_TOKEN]: { + inputId: 'feed_token', + containerTestId: 'feed-token-container', + }, + [INCOMING_EMAIL_TOKEN]: { + inputId: 'incoming_email_token', + containerTestId: 'incoming-email-token-container', + }, + [STATIC_OBJECT_TOKEN]: { + inputId: 'static_object_token', + containerTestId: 'static-object-token-container', + }, + }, + components: { Token, GlSprintf, GlLink }, + inject: ['tokenTypes'], + computed: { + enabledTokenTypes() { + return pickBy(this.tokenTypes, (tokenData, tokenType) => { + return ( + tokenData?.enabled && + this.$options.i18n[tokenType] && + this.$options.htmlAttributes[tokenType] + ); + }); + }, + }, +}; +</script> + +<template> + <div> + <token + v-for="(tokenData, tokenType) in enabledTokenTypes" + :key="tokenType" + :token="tokenData.token" + :input-id="$options.htmlAttributes[tokenType].inputId" + :input-label="$options.i18n[tokenType].label" + :copy-button-title="$options.i18n[tokenType].copyButtonTitle" + :data-testid="$options.htmlAttributes[tokenType].containerTestId" + > + <template #title>{{ $options.i18n[tokenType].label }}</template> + <template #description> + <p>{{ $options.i18n[tokenType].description }}</p> + <p>{{ $options.i18n.canNotAccessOtherData }}</p> + </template> + <template #input-description> + <gl-sprintf :message="$options.i18n[tokenType].inputDescription"> + <template #link="{ content }"> + <gl-link + :href="tokenData.resetPath" + :data-confirm="$options.i18n[tokenType].resetConfirmMessage" + data-method="put" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </template> + </token> + </div> +</template> diff --git a/app/assets/javascripts/access_tokens/constants.js b/app/assets/javascripts/access_tokens/constants.js new file mode 100644 index 00000000000..6188c6d1bb5 --- /dev/null +++ b/app/assets/javascripts/access_tokens/constants.js @@ -0,0 +1,4 @@ +// Token types +export const FEED_TOKEN = 'feedToken'; +export const INCOMING_EMAIL_TOKEN = 'incomingEmailToken'; +export const STATIC_OBJECT_TOKEN = 'staticObjectToken'; diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js index 7f5f0403de6..9a1e7d877f8 100644 --- a/app/assets/javascripts/access_tokens/index.js +++ b/app/assets/javascripts/access_tokens/index.js @@ -1,9 +1,13 @@ import Vue from 'vue'; + import createFlash from '~/flash'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { parseRailsFormFields } from '~/lib/utils/forms'; import { __ } from '~/locale'; import ExpiresAtField from './components/expires_at_field.vue'; +import TokensApp from './components/tokens_app.vue'; +import { FEED_TOKEN, INCOMING_EMAIL_TOKEN, STATIC_OBJECT_TOKEN } from './constants'; export const initExpiresAtField = () => { const el = document.querySelector('.js-access-tokens-expires-at'); @@ -81,3 +85,29 @@ export const initProjectsField = () => { return null; }; + +export const initTokensApp = () => { + const el = document.getElementById('js-tokens-app'); + + if (!el) return false; + + const tokensData = convertObjectPropsToCamelCase(JSON.parse(el.dataset.tokensData), { + deep: true, + }); + + const tokenTypes = { + [FEED_TOKEN]: tokensData[FEED_TOKEN], + [INCOMING_EMAIL_TOKEN]: tokensData[INCOMING_EMAIL_TOKEN], + [STATIC_OBJECT_TOKEN]: tokensData[STATIC_OBJECT_TOKEN], + }; + + return new Vue({ + el, + provide: { + tokenTypes, + }, + render(createElement) { + return createElement(TokensApp); + }, + }); +}; diff --git a/app/assets/javascripts/admin/deploy_keys/components/table.vue b/app/assets/javascripts/admin/deploy_keys/components/table.vue index 97a5a2f2f32..29e8b9a724e 100644 --- a/app/assets/javascripts/admin/deploy_keys/components/table.vue +++ b/app/assets/javascripts/admin/deploy_keys/components/table.vue @@ -1,13 +1,33 @@ <script> -import { GlTable, GlButton } from '@gitlab/ui'; +import { GlTable, GlButton, GlPagination, GlLoadingIcon, GlEmptyState, GlModal } from '@gitlab/ui'; import { __ } from '~/locale'; +import Api, { DEFAULT_PER_PAGE } from '~/api'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { cleanLeadingSeparator } from '~/lib/utils/url_utility'; +import createFlash from '~/flash'; +import csrf from '~/lib/utils/csrf'; export default { name: 'DeployKeysTable', i18n: { pageTitle: __('Public deploy keys'), newDeployKeyButtonText: __('New deploy key'), + emptyStateTitle: __('No public deploy keys'), + emptyStateDescription: __( + 'Deploy keys grant read/write access to all repositories in your instance', + ), + delete: __('Delete deploy key'), + edit: __('Edit deploy key'), + pagination: { + next: __('Next'), + prev: __('Prev'), + }, + modal: { + title: __('Are you sure?'), + body: __('Are you sure you want to delete this deploy key?'), + }, + apiErrorMessage: __('An error occurred fetching the public deploy keys. Please try again.'), }, fields: [ { @@ -29,13 +49,118 @@ export default { { key: 'actions', label: __('Actions'), + tdClass: 'gl-lg-w-1px gl-white-space-nowrap', + thClass: 'gl-lg-w-1px gl-white-space-nowrap', }, ], + modal: { + id: 'delete-deploy-key-modal', + actionPrimary: { + text: __('Delete'), + attributes: { + variant: 'danger', + }, + }, + actionSecondary: { + text: __('Cancel'), + attributes: { + variant: 'default', + }, + }, + }, + csrf, + DEFAULT_PER_PAGE, components: { GlTable, GlButton, + GlPagination, + TimeAgoTooltip, + GlLoadingIcon, + GlEmptyState, + GlModal, }, inject: ['editPath', 'deletePath', 'createPath', 'emptyStateSvgPath'], + data() { + return { + page: 1, + totalItems: 0, + loading: false, + items: [], + deployKeyToDelete: null, + }; + }, + computed: { + shouldShowTable() { + return this.totalItems !== 0 || this.loading; + }, + isModalVisible() { + return this.deployKeyToDelete !== null; + }, + deleteAction() { + return this.deployKeyToDelete === null + ? null + : this.deletePath.replace(':id', this.deployKeyToDelete); + }, + }, + watch: { + page(newPage) { + this.fetchDeployKeys(newPage); + }, + }, + mounted() { + this.fetchDeployKeys(); + }, + methods: { + editHref(id) { + return this.editPath.replace(':id', id); + }, + projectHref(project) { + return `/${cleanLeadingSeparator(project.path_with_namespace)}`; + }, + async fetchDeployKeys(page) { + this.loading = true; + try { + const { headers, data: items } = await Api.deployKeys({ + page, + public: true, + }); + + if (this.totalItems === 0) { + this.totalItems = parseInt(headers?.['x-total'], 10) || 0; + } + + this.items = items.map( + ({ id, title, fingerprint, projects_with_write_access, created_at }) => ({ + id, + title, + fingerprint, + projects: projects_with_write_access, + created: created_at, + }), + ); + } catch (error) { + createFlash({ + message: this.$options.i18n.apiErrorMessage, + captureError: true, + error, + }); + + this.totalItems = 0; + + this.items = []; + } + this.loading = false; + }, + handleDeleteClick(id) { + this.deployKeyToDelete = id; + }, + handleModalHide() { + this.deployKeyToDelete = null; + }, + handleModalPrimary() { + this.$refs.modalForm.submit(); + }, + }, }; </script> @@ -45,10 +170,92 @@ export default { <h4 class="gl-m-0"> {{ $options.i18n.pageTitle }} </h4> - <gl-button variant="confirm" :href="createPath">{{ + <gl-button variant="confirm" :href="createPath" data-testid="new-deploy-key-button">{{ $options.i18n.newDeployKeyButtonText }}</gl-button> </div> - <gl-table :fields="$options.fields" data-testid="deploy-keys-list" /> + <template v-if="shouldShowTable"> + <gl-table + :busy="loading" + :items="items" + :fields="$options.fields" + stacked="lg" + data-testid="deploy-keys-list" + > + <template #table-busy> + <gl-loading-icon size="lg" class="gl-my-5" /> + </template> + + <template #cell(projects)="{ item: { projects } }"> + <a + v-for="project in projects" + :key="project.id" + :href="projectHref(project)" + class="gl-display-block" + >{{ project.name_with_namespace }}</a + > + </template> + + <template #cell(fingerprint)="{ item: { fingerprint } }"> + <code>{{ fingerprint }}</code> + </template> + + <template #cell(created)="{ item: { created } }"> + <time-ago-tooltip :time="created" /> + </template> + + <template #head(actions)="{ label }"> + <span class="gl-sr-only">{{ label }}</span> + </template> + + <template #cell(actions)="{ item: { id } }"> + <gl-button + icon="pencil" + :aria-label="$options.i18n.edit" + :href="editHref(id)" + class="gl-mr-2" + /> + <gl-button + variant="danger" + icon="remove" + :aria-label="$options.i18n.delete" + @click="handleDeleteClick(id)" + /> + </template> + </gl-table> + <gl-pagination + v-if="!loading" + v-model="page" + :per-page="$options.DEFAULT_PER_PAGE" + :total-items="totalItems" + :next-text="$options.i18n.pagination.next" + :prev-text="$options.i18n.pagination.prev" + align="center" + /> + </template> + <gl-empty-state + v-else + :svg-path="emptyStateSvgPath" + :title="$options.i18n.emptyStateTitle" + :description="$options.i18n.emptyStateDescription" + :primary-button-text="$options.i18n.newDeployKeyButtonText" + :primary-button-link="createPath" + /> + <gl-modal + :modal-id="$options.modal.id" + :visible="isModalVisible" + :title="$options.i18n.modal.title" + :action-primary="$options.modal.actionPrimary" + :action-secondary="$options.modal.actionSecondary" + size="sm" + @hide="handleModalHide" + @primary="handleModalPrimary" + > + <form ref="modalForm" :action="deleteAction" method="post"> + <input type="hidden" name="_method" value="delete" /> + <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> + </form> + {{ $options.i18n.modal.body }} + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/activate.vue b/app/assets/javascripts/admin/users/components/actions/activate.vue index 74e9c60a57b..3a54035c587 100644 --- a/app/assets/javascripts/admin/users/components/actions/activate.vue +++ b/app/assets/javascripts/admin/users/components/actions/activate.vue @@ -1,6 +1,7 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; +import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; // TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922 @@ -26,16 +27,15 @@ export default { required: true, }, }, - computed: { - modalAttributes() { - return { - 'data-path': this.path, - 'data-method': 'put', - 'data-modal-attributes': JSON.stringify({ + methods: { + onClick() { + eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, { + path: this.path, + method: 'put', + modalAttributes: { title: sprintf(s__('AdminUsers|Activate user %{username}?'), { username: this.username, }), - messageHtml, actionCancel: { text: __('Cancel'), }, @@ -43,15 +43,16 @@ export default { text: I18N_USER_ACTIONS.activate, attributes: [{ variant: 'confirm' }], }, - }), - }; + messageHtml, + }, + }); }, }, }; </script> <template> - <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> + <gl-dropdown-item @click="onClick"> <slot></slot> </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/approve.vue b/app/assets/javascripts/admin/users/components/actions/approve.vue index 77a9be8eec2..5a8c675822d 100644 --- a/app/assets/javascripts/admin/users/components/actions/approve.vue +++ b/app/assets/javascripts/admin/users/components/actions/approve.vue @@ -1,6 +1,7 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; +import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; // TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922 @@ -28,12 +29,12 @@ export default { required: true, }, }, - computed: { - attributes() { - return { - 'data-path': this.path, - 'data-method': 'put', - 'data-modal-attributes': JSON.stringify({ + methods: { + onClick() { + eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, { + path: this.path, + method: 'put', + modalAttributes: { title: sprintf(s__('AdminUsers|Approve user %{username}?'), { username: this.username, }), @@ -45,16 +46,15 @@ export default { attributes: [{ variant: 'confirm', 'data-qa-selector': 'approve_user_confirm_button' }], }, messageHtml, - }), - 'data-qa-selector': 'approve_user_button', - }; + }, + }); }, }, }; </script> <template> - <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...attributes }"> + <gl-dropdown-item data-qa-selector="approve_user_button" @click="onClick"> <slot></slot> </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/ban.vue b/app/assets/javascripts/admin/users/components/actions/ban.vue index e5ab0f9123f..55938832dce 100644 --- a/app/assets/javascripts/admin/users/components/actions/ban.vue +++ b/app/assets/javascripts/admin/users/components/actions/ban.vue @@ -2,6 +2,7 @@ import { GlDropdownItem } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { sprintf, s__, __ } from '~/locale'; +import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; // TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922 @@ -39,12 +40,12 @@ export default { required: true, }, }, - computed: { - modalAttributes() { - return { - 'data-path': this.path, - 'data-method': 'put', - 'data-modal-attributes': JSON.stringify({ + methods: { + onClick() { + eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, { + path: this.path, + method: 'put', + modalAttributes: { title: sprintf(s__('AdminUsers|Ban user %{username}?'), { username: this.username, }), @@ -56,15 +57,15 @@ export default { attributes: [{ variant: 'confirm' }], }, messageHtml, - }), - }; + }, + }); }, }, }; </script> <template> - <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> + <gl-dropdown-item @click="onClick"> <slot></slot> </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/block.vue b/app/assets/javascripts/admin/users/components/actions/block.vue index 03557008a89..d25dd400f9b 100644 --- a/app/assets/javascripts/admin/users/components/actions/block.vue +++ b/app/assets/javascripts/admin/users/components/actions/block.vue @@ -1,6 +1,7 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; +import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; // TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922 @@ -29,12 +30,12 @@ export default { required: true, }, }, - computed: { - modalAttributes() { - return { - 'data-path': this.path, - 'data-method': 'put', - 'data-modal-attributes': JSON.stringify({ + methods: { + onClick() { + eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, { + path: this.path, + method: 'put', + modalAttributes: { title: sprintf(s__('AdminUsers|Block user %{username}?'), { username: this.username }), actionCancel: { text: __('Cancel'), @@ -44,15 +45,15 @@ export default { attributes: [{ variant: 'confirm' }], }, messageHtml, - }), - }; + }, + }); }, }, }; </script> <template> - <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> + <gl-dropdown-item @click="onClick"> <slot></slot> </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/deactivate.vue b/app/assets/javascripts/admin/users/components/actions/deactivate.vue index 640c8fefc20..c85f3f01675 100644 --- a/app/assets/javascripts/admin/users/components/actions/deactivate.vue +++ b/app/assets/javascripts/admin/users/components/actions/deactivate.vue @@ -1,6 +1,7 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; +import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; // TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922 @@ -36,12 +37,12 @@ export default { required: true, }, }, - computed: { - modalAttributes() { - return { - 'data-path': this.path, - 'data-method': 'put', - 'data-modal-attributes': JSON.stringify({ + methods: { + onClick() { + eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, { + path: this.path, + method: 'put', + modalAttributes: { title: sprintf(s__('AdminUsers|Deactivate user %{username}?'), { username: this.username, }), @@ -53,15 +54,15 @@ export default { attributes: [{ variant: 'confirm' }], }, messageHtml, - }), - }; + }, + }); }, }, }; </script> <template> - <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> + <gl-dropdown-item @click="onClick"> <slot></slot> </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/reject.vue b/app/assets/javascripts/admin/users/components/actions/reject.vue index 901306455fa..bac08de1d5e 100644 --- a/app/assets/javascripts/admin/users/components/actions/reject.vue +++ b/app/assets/javascripts/admin/users/components/actions/reject.vue @@ -2,6 +2,7 @@ import { GlDropdownItem } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { sprintf, s__, __ } from '~/locale'; +import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; // TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922 @@ -39,12 +40,12 @@ export default { required: true, }, }, - computed: { - modalAttributes() { - return { - 'data-path': this.path, - 'data-method': 'delete', - 'data-modal-attributes': JSON.stringify({ + methods: { + onClick() { + eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, { + path: this.path, + method: 'delete', + modalAttributes: { title: sprintf(s__('AdminUsers|Reject user %{username}?'), { username: this.username, }), @@ -56,15 +57,15 @@ export default { attributes: [{ variant: 'danger' }], }, messageHtml, - }), - }; + }, + }); }, }, }; </script> <template> - <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> + <gl-dropdown-item @click="onClick"> <slot></slot> </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/unban.vue b/app/assets/javascripts/admin/users/components/actions/unban.vue index 8083e26177e..beede2d37d7 100644 --- a/app/assets/javascripts/admin/users/components/actions/unban.vue +++ b/app/assets/javascripts/admin/users/components/actions/unban.vue @@ -1,6 +1,7 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; +import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; // TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922 @@ -22,12 +23,12 @@ export default { required: true, }, }, - computed: { - modalAttributes() { - return { - 'data-path': this.path, - 'data-method': 'put', - 'data-modal-attributes': JSON.stringify({ + methods: { + onClick() { + eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, { + path: this.path, + method: 'put', + modalAttributes: { title: sprintf(s__('AdminUsers|Unban user %{username}?'), { username: this.username, }), @@ -39,15 +40,15 @@ export default { attributes: [{ variant: 'confirm' }], }, messageHtml, - }), - }; + }, + }); }, }, }; </script> <template> - <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> + <gl-dropdown-item @click="onClick"> <slot></slot> </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/unblock.vue b/app/assets/javascripts/admin/users/components/actions/unblock.vue index 7de6653e0cd..720f2efd932 100644 --- a/app/assets/javascripts/admin/users/components/actions/unblock.vue +++ b/app/assets/javascripts/admin/users/components/actions/unblock.vue @@ -1,6 +1,7 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; +import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; export default { @@ -17,12 +18,13 @@ export default { required: true, }, }, - computed: { - modalAttributes() { - return { - 'data-path': this.path, - 'data-method': 'put', - 'data-modal-attributes': JSON.stringify({ + + methods: { + onClick() { + eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, { + path: this.path, + method: 'put', + modalAttributes: { title: sprintf(s__('AdminUsers|Unblock user %{username}?'), { username: this.username }), message: s__('AdminUsers|You can always block their account again if needed.'), actionCancel: { @@ -32,15 +34,15 @@ export default { text: I18N_USER_ACTIONS.unblock, attributes: [{ variant: 'confirm' }], }, - }), - }; + }, + }); }, }, }; </script> <template> - <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> + <gl-dropdown-item @click="onClick"> <slot></slot> </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/unlock.vue b/app/assets/javascripts/admin/users/components/actions/unlock.vue index 10d4fb06d61..55ea3e0aba7 100644 --- a/app/assets/javascripts/admin/users/components/actions/unlock.vue +++ b/app/assets/javascripts/admin/users/components/actions/unlock.vue @@ -1,6 +1,7 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; import { sprintf, s__, __ } from '~/locale'; +import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; import { I18N_USER_ACTIONS } from '../../constants'; export default { @@ -17,12 +18,12 @@ export default { required: true, }, }, - computed: { - modalAttributes() { - return { - 'data-path': this.path, - 'data-method': 'put', - 'data-modal-attributes': JSON.stringify({ + methods: { + onClick() { + eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, { + path: this.path, + method: 'put', + modalAttributes: { title: sprintf(s__('AdminUsers|Unlock user %{username}?'), { username: this.username }), message: __('Are you sure?'), actionCancel: { @@ -32,15 +33,15 @@ export default { text: I18N_USER_ACTIONS.unlock, attributes: [{ variant: 'confirm' }], }, - }), - }; + }, + }); }, }, }; </script> <template> - <gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }"> + <gl-dropdown-item @click="onClick"> <slot></slot> </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue index e949498c55b..d7c08096376 100644 --- a/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue +++ b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue @@ -57,14 +57,17 @@ export default { }; }, computed: { + trimmedUsername() { + return this.username.trim(); + }, modalTitle() { - return sprintf(this.title, { username: this.username }, false); + return sprintf(this.title, { username: this.trimmedUsername }, false); }, secondaryButtonLabel() { return s__('AdminUsers|Block user'); }, canSubmit() { - return this.enteredUsername === this.username; + return this.enteredUsername === this.trimmedUsername; }, obstacles() { try { @@ -104,7 +107,7 @@ export default { <p> <gl-sprintf :message="content"> <template #username> - <strong>{{ username }}</strong> + <strong>{{ trimmedUsername }}</strong> </template> <template #strong="props"> <strong>{{ props.content }}</strong> @@ -115,13 +118,13 @@ export default { <user-deletion-obstacles-list v-if="obstacles.length" :obstacles="obstacles" - :user-name="username" + :user-name="trimmedUsername" /> <p> <gl-sprintf :message="s__('AdminUsers|To confirm, type %{username}')"> <template #username> - <code class="gl-white-space-pre-wrap">{{ username }}</code> + <code class="gl-white-space-pre-wrap">{{ trimmedUsername }}</code> </template> </gl-sprintf> </p> diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue index 4f4e2947341..567d7151847 100644 --- a/app/assets/javascripts/admin/users/components/user_actions.vue +++ b/app/assets/javascripts/admin/users/components/user_actions.vue @@ -112,7 +112,7 @@ export default { right :text="$options.i18n.userAdministration" :text-sr-only="!showButtonLabels" - icon="settings" + icon="ellipsis_h" data-qa-selector="user_actions_dropdown_toggle" :data-qa-username="user.username" > diff --git a/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql index 40ec4c56171..0f9075c58bf 100644 --- a/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql +++ b/app/assets/javascripts/alert_management/graphql/queries/get_count_by_status.query.graphql @@ -1,5 +1,6 @@ query getAlertsCount($searchTerm: String, $projectPath: ID!, $assigneeUsername: String = "") { project(fullPath: $projectPath) { + id alertManagementAlertStatusCounts(search: $searchTerm, assigneeUsername: $assigneeUsername) { all open diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql index babcdea935d..d4f4f244759 100644 --- a/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql @@ -3,6 +3,8 @@ mutation createHttpIntegration($projectPath: ID!, $name: String!, $active: Boolean!) { httpIntegrationCreate(input: { projectPath: $projectPath, name: $name, active: $active }) { errors + # We have ID in a deeply nested fragment + # eslint-disable-next-line @graphql-eslint/require-id-when-available integration { ...HttpIntegrationItem } diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql index a3a50651fd0..caa258e0848 100644 --- a/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql @@ -3,6 +3,8 @@ mutation destroyHttpIntegration($id: ID!) { httpIntegrationDestroy(input: { id: $id }) { errors + # We have ID in a deeply nested fragment + # eslint-disable-next-line @graphql-eslint/require-id-when-available integration { ...HttpIntegrationItem } diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql index c0754d8e32b..2f30f9abb5c 100644 --- a/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql @@ -3,6 +3,8 @@ mutation resetHttpIntegrationToken($id: ID!) { httpIntegrationResetToken(input: { id: $id }) { errors + # We have ID in a deeply nested fragment + # eslint-disable-next-line @graphql-eslint/require-id-when-available integration { ...HttpIntegrationItem } diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql index 37df9ec25eb..2cf56613673 100644 --- a/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql @@ -3,6 +3,8 @@ mutation updateHttpIntegration($id: ID!, $name: String!, $active: Boolean!) { httpIntegrationUpdate(input: { id: $id, name: $name, active: $active }) { errors + # We have ID in a deeply nested fragment + # eslint-disable-next-line @graphql-eslint/require-id-when-available integration { ...HttpIntegrationItem } diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integration.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integration.query.graphql index d20a8b8334b..7299e6836d4 100644 --- a/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integration.query.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integration.query.graphql @@ -2,6 +2,7 @@ query getHttpIntegration($projectPath: ID!, $id: ID) { project(fullPath: $projectPath) { + id alertManagementHttpIntegrations(id: $id) { nodes { ...HttpIntegrationPayloadData diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql index 228dd5fb176..3cd3f2d92f8 100644 --- a/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql @@ -2,6 +2,7 @@ query getIntegrations($projectPath: ID!) { project(fullPath: $projectPath) { + id alertManagementIntegrations { nodes { ...IntegrationItem diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/parse_sample_payload.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/parse_sample_payload.query.graphql index 159b2661f0b..15df4a08cc2 100644 --- a/app/assets/javascripts/alerts_settings/graphql/queries/parse_sample_payload.query.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/queries/parse_sample_payload.query.graphql @@ -1,5 +1,6 @@ query parsePayloadFields($projectPath: ID!, $payload: String!) { project(fullPath: $projectPath) { + id alertManagementPayloadFields(payloadExample: $payload) { path label diff --git a/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue b/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue index 238081cc3c0..5a394059931 100644 --- a/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue +++ b/app/assets/javascripts/analytics/devops_reports/components/devops_score.vue @@ -1,5 +1,5 @@ <script> -import { GlBadge, GlTable, GlLink, GlEmptyState } from '@gitlab/ui'; +import { GlBadge, GlTableLite, GlLink, GlEmptyState } from '@gitlab/ui'; import { GlSingleStat } from '@gitlab/ui/dist/charts'; import { helpPagePath } from '~/helpers/help_page_helper'; import { sprintf, s__ } from '~/locale'; @@ -13,7 +13,7 @@ const defaultHeaderAttrs = { export default { components: { GlBadge, - GlTable, + GlTableLite, GlSingleStat, GlLink, GlEmptyState, @@ -94,7 +94,7 @@ export default { :meta-text="devopsScoreMetrics.averageScore.scoreLevel.label" :variant="devopsScoreMetrics.averageScore.scoreLevel.variant" /> - <gl-table + <gl-table-lite :fields="$options.tableHeaderFields" :items="devopsScoreMetrics.cards" thead-class="gl-border-t-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-100" @@ -108,7 +108,7 @@ export default { }}</gl-badge> </div> </template> - </gl-table> + </gl-table-lite> </div> </div> </template> diff --git a/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql b/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql index b870ed4dcbf..ea2f911fb54 100644 --- a/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql +++ b/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql @@ -5,6 +5,7 @@ query analyticsGetGroupProjects( $includeSubgroups: Boolean = false ) { group(fullPath: $groupFullPath) { + id projects( search: $search first: $first diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index adf3e122a64..8c996b448aa 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -91,6 +91,7 @@ const Api = { projectNotificationSettingsPath: '/api/:version/projects/:id/notification_settings', groupNotificationSettingsPath: '/api/:version/groups/:id/notification_settings', notificationSettingsPath: '/api/:version/notification_settings', + deployKeysPath: '/api/:version/deploy_keys', group(groupId, callback = () => {}) { const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); @@ -950,6 +951,12 @@ const Api = { return axios.delete(url); }, + deployKeys(params = {}) { + const url = Api.buildUrl(this.deployKeysPath); + + return axios.get(url, { params: { per_page: DEFAULT_PER_PAGE, ...params } }); + }, + async updateNotificationSettings(projectId, groupId, data = {}) { let url = Api.buildUrl(this.notificationSettingsPath); diff --git a/app/assets/javascripts/api/packages_api.js b/app/assets/javascripts/api/packages_api.js new file mode 100644 index 00000000000..47f51c7e80e --- /dev/null +++ b/app/assets/javascripts/api/packages_api.js @@ -0,0 +1,32 @@ +import axios from '../lib/utils/axios_utils'; +import { buildApiUrl } from './api_utils'; + +const PUBLISH_PACKAGE_PATH = + '/api/:version/projects/:id/packages/generic/:package_name/:package_version/:file_name'; + +export function publishPackage( + { projectPath, name, version, fileName, files }, + options, + axiosOptions = {}, +) { + const url = buildApiUrl(PUBLISH_PACKAGE_PATH) + .replace(':id', encodeURIComponent(projectPath)) + .replace(':package_name', name) + .replace(':package_version', version) + .replace(':file_name', fileName); + + const defaults = { + status: 'default', + }; + + const formData = new FormData(); + formData.append('file', files[0]); + + return axios.put(url, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + params: Object.assign(defaults, options), + ...axiosOptions, + }); +} diff --git a/app/assets/javascripts/artifacts_settings/graphql/queries/get_keep_latest_artifact_project_setting.query.graphql b/app/assets/javascripts/artifacts_settings/graphql/queries/get_keep_latest_artifact_project_setting.query.graphql index 7486512c57c..91fa468fc8c 100644 --- a/app/assets/javascripts/artifacts_settings/graphql/queries/get_keep_latest_artifact_project_setting.query.graphql +++ b/app/assets/javascripts/artifacts_settings/graphql/queries/get_keep_latest_artifact_project_setting.query.graphql @@ -1,5 +1,6 @@ query getKeepLatestArtifactProjectSetting($fullPath: ID!) { project(fullPath: $fullPath) { + id ciCdSettings { keepLatestArtifact } diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue index 918519f386b..a218624f2d4 100644 --- a/app/assets/javascripts/batch_comments/components/draft_note.vue +++ b/app/assets/javascripts/batch_comments/components/draft_note.vue @@ -76,7 +76,7 @@ export default { }, }, safeHtmlConfig: { - ADD_TAGS: ['use', 'gl-emoji'], + ADD_TAGS: ['use', 'gl-emoji', 'copy-code'], }, }; </script> diff --git a/app/assets/javascripts/behaviors/copy_code.js b/app/assets/javascripts/behaviors/copy_code.js new file mode 100644 index 00000000000..a6e203ea5a2 --- /dev/null +++ b/app/assets/javascripts/behaviors/copy_code.js @@ -0,0 +1,66 @@ +import { uniqueId } from 'lodash'; +import { __ } from '~/locale'; +import { spriteIcon } from '~/lib/utils/common_utils'; +import { setAttributes } from '~/lib/utils/dom_utils'; + +class CopyCodeButton extends HTMLElement { + connectedCallback() { + this.for = uniqueId('code-'); + + this.parentNode.querySelector('pre').setAttribute('id', this.for); + + this.appendChild(this.createButton()); + } + + createButton() { + const button = document.createElement('button'); + + setAttributes(button, { + type: 'button', + class: 'btn btn-default btn-md gl-button btn-icon has-tooltip', + 'data-title': __('Copy to clipboard'), + 'data-clipboard-target': `pre#${this.for}`, + }); + + button.innerHTML = spriteIcon('copy-to-clipboard'); + + return button; + } +} + +function addCodeButton() { + [...document.querySelectorAll('pre.code.js-syntax-highlight')] + .filter((el) => !el.closest('.js-markdown-code')) + .forEach((el) => { + const copyCodeEl = document.createElement('copy-code'); + copyCodeEl.setAttribute('for', uniqueId('code-')); + + const wrapper = document.createElement('div'); + wrapper.className = 'gl-relative markdown-code-block js-markdown-code'; + wrapper.appendChild(el.cloneNode(true)); + wrapper.appendChild(copyCodeEl); + + el.parentNode.insertBefore(wrapper, el); + + el.remove(); + }); +} + +export const initCopyCodeButton = (selector = '#content-body') => { + if (!customElements.get('copy-code')) { + customElements.define('copy-code', CopyCodeButton); + } + + const el = document.querySelector(selector); + + if (!el) return () => {}; + + const observer = new MutationObserver(() => addCodeButton()); + + observer.observe(document.querySelector(selector), { + childList: true, + subtree: true, + }); + + return () => observer.disconnect(); +}; diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js index ef445548e6e..8fe90b6bb15 100644 --- a/app/assets/javascripts/behaviors/gl_emoji.js +++ b/app/assets/javascripts/behaviors/gl_emoji.js @@ -33,7 +33,7 @@ class GlEmoji extends HTMLElement { this.dataset.unicodeVersion = unicodeVersion; emojiUnicode = emojiInfo.e; - this.innerHTML = emojiInfo.e; + this.textContent = emojiInfo.e; this.title = emojiInfo.d; } diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js index bfd025e8dab..30160248a77 100644 --- a/app/assets/javascripts/behaviors/index.js +++ b/app/assets/javascripts/behaviors/index.js @@ -1,6 +1,5 @@ import $ from 'jquery'; import './autosize'; -import './bind_in_out'; import './markdown/render_gfm'; import initCollapseSidebarOnWindowResize from './collapse_sidebar_on_window_resize'; import initCopyToClipboard from './copy_to_clipboard'; diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js index c2908133fd0..e58c51104c5 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js @@ -3,6 +3,7 @@ import Mousetrap from 'mousetrap'; import { clickCopyToClipboardButton } from '~/behaviors/copy_to_clipboard'; import { getSelectedFragment } from '~/lib/utils/common_utils'; import { isElementVisible } from '~/lib/utils/dom_utils'; +import { DEBOUNCE_DROPDOWN_DELAY } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import Sidebar from '../../right_sidebar'; import { CopyAsGFM } from '../markdown/copy_as_gfm'; import { @@ -114,6 +115,14 @@ export default class ShortcutsIssuable extends Shortcuts { static openSidebarDropdown(name) { Sidebar.instance.openDropdown(name); + // Wait for the sidebar to trigger('click') open + // so it doesn't cause our dropdown to close preemptively + setTimeout(() => { + const editBtn = + document.querySelector(`.block.${name} .shortcut-sidebar-dropdown-toggle`) || + document.querySelector(`.block.${name} .edit-link`); + editBtn.click(); + }, DEBOUNCE_DROPDOWN_DELAY); return false; } diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue index e3e43ea3a0e..9832ebbea5c 100644 --- a/app/assets/javascripts/blob/components/blob_content.vue +++ b/app/assets/javascripts/blob/components/blob_content.vue @@ -86,7 +86,7 @@ export default { :file-name="blob.name" :type="activeViewer.fileType" :hide-line-numbers="hideLineNumbers" - data-qa-selector="file_content" + data-qa-selector="blob_viewer_file_content" /> </template> </div> diff --git a/app/assets/javascripts/blob/pdf/pdf_viewer.vue b/app/assets/javascripts/blob/pdf/pdf_viewer.vue index 96d6f500960..a1a62abeb6f 100644 --- a/app/assets/javascripts/blob/pdf/pdf_viewer.vue +++ b/app/assets/javascripts/blob/pdf/pdf_viewer.vue @@ -38,7 +38,13 @@ export default { <div v-if="loading && !error" class="text-center loading"> <gl-loading-icon class="mt-5" size="lg" /> </div> - <pdf-lab v-if="!loadError" :pdf="pdf" @pdflabload="onLoad" @pdflaberror="onError" /> + <pdf-lab + v-if="!loadError" + :pdf="pdf" + @pdflabload="onLoad" + @pdflaberror="onError" + v-on="$listeners" + /> <p v-if="error" class="text-center"> <span v-if="loadError" ref="loadError"> {{ __('An error occurred while loading the file. Please try again later.') }} diff --git a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue index e75aa523ed0..47a0c4ba2d1 100644 --- a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue +++ b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue @@ -71,7 +71,7 @@ export default { i18n: { modalTitle: __("That's it, well done!"), pipelinesButton: s__('MR widget|See your pipeline in action'), - mergeRequestButton: s__('MR widget|Back to the Merge request'), + mergeRequestButton: s__('MR widget|Back to the merge request'), bodyMessage: s__( `MR widget|The pipeline will test your code on every commit. A %{codeQualityLinkStart}code quality report%{codeQualityLinkEnd} will appear in your merge requests to warn you about potential code degradations.`, ), diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index 118cef59d5a..ee2f6cfb46c 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext'; import SourceEditor from '~/editor/source_editor'; import { getBlobLanguage } from '~/editor/utils'; @@ -26,23 +27,29 @@ export default class EditBlob { this.editor.focus(); } - fetchMarkdownExtension() { - import('~/editor/extensions/source_editor_markdown_ext') - .then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => { - this.editor.use( - new MarkdownExtension({ - instance: this.editor, - previewMarkdownPath: this.options.previewMarkdownPath, - }), - ); - this.hasMarkdownExtension = true; - addEditorMarkdownListeners(this.editor); - }) - .catch((e) => - createFlash({ - message: `${BLOB_EDITOR_ERROR}: ${e}`, - }), - ); + async fetchMarkdownExtension() { + try { + const [ + { EditorMarkdownExtension: MarkdownExtension }, + { EditorMarkdownPreviewExtension: MarkdownLivePreview }, + ] = await Promise.all([ + import('~/editor/extensions/source_editor_markdown_ext'), + import('~/editor/extensions/source_editor_markdown_livepreview_ext'), + ]); + this.editor.use([ + { definition: MarkdownExtension }, + { + definition: MarkdownLivePreview, + setupOptions: { previewMarkdownPath: this.options.previewMarkdownPath }, + }, + ]); + } catch (e) { + createFlash({ + message: `${BLOB_EDITOR_ERROR}: ${e}`, + }); + } + this.hasMarkdownExtension = true; + addEditorMarkdownListeners(this.editor); } configureMonacoEditor() { @@ -60,7 +67,7 @@ export default class EditBlob { blobPath: fileNameEl.value, blobContent: editorEl.innerText, }); - this.editor.use(new FileTemplateExtension({ instance: this.editor })); + this.editor.use([{ definition: SourceEditorExtension }, { definition: FileTemplateExtension }]); fileNameEl.addEventListener('change', () => { this.editor.updateModelLanguage(fileNameEl.value); diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index e6c91c7ac1f..7e4d3ebb686 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -1,6 +1,6 @@ import { sortBy, cloneDeep } from 'lodash'; import { isGid } from '~/graphql_shared/utils'; -import { ListType, MilestoneIDs } from './constants'; +import { ListType, MilestoneIDs, AssigneeFilterType, MilestoneFilterType } from './constants'; export function getMilestone() { return null; @@ -186,6 +186,7 @@ export function isListDraggable(list) { export const FiltersInfo = { assigneeUsername: { negatedSupport: true, + remap: (k, v) => (v === AssigneeFilterType.any ? 'assigneeWildcardId' : k), }, assigneeId: { // assigneeId should be renamed to assigneeWildcardId. @@ -204,6 +205,11 @@ export const FiltersInfo = { }, milestoneTitle: { negatedSupport: true, + remap: (k, v) => (Object.values(MilestoneFilterType).includes(v) ? 'milestoneWildcardId' : k), + }, + milestoneWildcardId: { + negatedSupport: true, + transform: (val) => val.toUpperCase(), }, myReactionEmoji: { negatedSupport: true, @@ -214,6 +220,10 @@ export const FiltersInfo = { types: { negatedSupport: true, }, + confidential: { + negatedSupport: false, + transform: (val) => val === 'yes', + }, search: { negatedSupport: false, }, diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index b6ccc6a00fe..ea80496c3f5 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -13,7 +13,7 @@ import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { updateHistory } from '~/lib/utils/url_utility'; import { sprintf, __, n__ } from '~/locale'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import { ListType } from '../constants'; import eventHub from '../eventhub'; diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index 54668c9e88e..f89f8e5feb8 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -4,7 +4,6 @@ import { MountingPortal } from 'portal-vue'; import { mapState, mapActions, mapGetters } from 'vuex'; import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue'; import { __, sprintf } from '~/locale'; -import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue'; import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; import { ISSUABLE } from '~/boards/constants'; @@ -26,7 +25,6 @@ export default { SidebarDateWidget, SidebarConfidentialityWidget, BoardSidebarTimeTracker, - BoardSidebarLabelsSelect, SidebarLabelsWidget, SidebarSubscriptionsWidget, SidebarDropdownWidget, @@ -210,7 +208,6 @@ export default { data-testid="sidebar-due-date" /> <sidebar-labels-widget - v-if="glFeatures.labelsWidget" class="block labels" data-testid="sidebar-labels" :iid="activeBoardItem.iid" @@ -230,7 +227,6 @@ export default { > {{ __('None') }} </sidebar-labels-widget> - <board-sidebar-labels-select v-else class="block labels" /> <sidebar-weight-widget v-if="weightFeatureAvailable" :iid="activeBoardItem.iid" diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue index 6e6ada2d109..09ec385bbba 100644 --- a/app/assets/javascripts/boards/components/board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/board_filtered_search.vue @@ -1,7 +1,7 @@ <script> import { pickBy, isEmpty } from 'lodash'; import { mapActions } from 'vuex'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; @@ -39,30 +39,33 @@ export default { assigneeUsername, search, milestoneTitle, + iterationId, types, weight, epicId, myReactionEmoji, + releaseTag, + confidential, } = this.filterParams; const filteredSearchValue = []; if (authorUsername) { filteredSearchValue.push({ - type: 'author_username', + type: 'author', value: { data: authorUsername, operator: '=' }, }); } if (assigneeUsername) { filteredSearchValue.push({ - type: 'assignee_username', + type: 'assignee', value: { data: assigneeUsername, operator: '=' }, }); } if (types) { filteredSearchValue.push({ - type: 'types', + type: 'type', value: { data: types, operator: '=' }, }); } @@ -70,7 +73,7 @@ export default { if (labelName?.length) { filteredSearchValue.push( ...labelName.map((label) => ({ - type: 'label_name', + type: 'label', value: { data: label, operator: '=' }, })), ); @@ -78,11 +81,18 @@ export default { if (milestoneTitle) { filteredSearchValue.push({ - type: 'milestone_title', + type: 'milestone', value: { data: milestoneTitle, operator: '=' }, }); } + if (iterationId) { + filteredSearchValue.push({ + type: 'iteration', + value: { data: iterationId, operator: '=' }, + }); + } + if (weight) { filteredSearchValue.push({ type: 'weight', @@ -92,32 +102,53 @@ export default { if (myReactionEmoji) { filteredSearchValue.push({ - type: 'my_reaction_emoji', + type: 'my-reaction', value: { data: myReactionEmoji, operator: '=' }, }); } + if (releaseTag) { + filteredSearchValue.push({ + type: 'release', + value: { data: releaseTag, operator: '=' }, + }); + } + + if (confidential !== undefined) { + filteredSearchValue.push({ + type: 'confidential', + value: { data: confidential }, + }); + } + if (epicId) { filteredSearchValue.push({ - type: 'epic_id', + type: 'epic', value: { data: epicId, operator: '=' }, }); } if (this.filterParams['not[authorUsername]']) { filteredSearchValue.push({ - type: 'author_username', + type: 'author', value: { data: this.filterParams['not[authorUsername]'], operator: '!=' }, }); } if (this.filterParams['not[milestoneTitle]']) { filteredSearchValue.push({ - type: 'milestone_title', + type: 'milestone', value: { data: this.filterParams['not[milestoneTitle]'], operator: '!=' }, }); } + if (this.filterParams['not[iteration_id]']) { + filteredSearchValue.push({ + type: 'iteration_id', + value: { data: this.filterParams['not[iteration_id]'], operator: '!=' }, + }); + } + if (this.filterParams['not[weight]']) { filteredSearchValue.push({ type: 'weight', @@ -127,7 +158,7 @@ export default { if (this.filterParams['not[assigneeUsername]']) { filteredSearchValue.push({ - type: 'assignee_username', + type: 'assignee', value: { data: this.filterParams['not[assigneeUsername]'], operator: '!=' }, }); } @@ -135,7 +166,7 @@ export default { if (this.filterParams['not[labelName]']) { filteredSearchValue.push( ...this.filterParams['not[labelName]'].map((label) => ({ - type: 'label_name', + type: 'label', value: { data: label, operator: '!=' }, })), ); @@ -143,25 +174,32 @@ export default { if (this.filterParams['not[types]']) { filteredSearchValue.push({ - type: 'types', + type: 'type', value: { data: this.filterParams['not[types]'], operator: '!=' }, }); } if (this.filterParams['not[epicId]']) { filteredSearchValue.push({ - type: 'epic_id', + type: 'epic', value: { data: this.filterParams['not[epicId]'], operator: '!=' }, }); } if (this.filterParams['not[myReactionEmoji]']) { filteredSearchValue.push({ - type: 'my_reaction_emoji', + type: 'my-reaction', value: { data: this.filterParams['not[myReactionEmoji]'], operator: '!=' }, }); } + if (this.filterParams['not[releaseTag]']) { + filteredSearchValue.push({ + type: 'release', + value: { data: this.filterParams['not[releaseTag]'], operator: '!=' }, + }); + } + if (search) { filteredSearchValue.push(search); } @@ -179,8 +217,10 @@ export default { weight, epicId, myReactionEmoji, + iterationId, + releaseTag, + confidential, } = this.filterParams; - let notParams = {}; if (Object.prototype.hasOwnProperty.call(this.filterParams, 'not')) { @@ -194,6 +234,8 @@ export default { 'not[weight]': this.filterParams.not.weight, 'not[epic_id]': this.filterParams.not.epicId, 'not[my_reaction_emoji]': this.filterParams.not.myReactionEmoji, + 'not[iteration_id]': this.filterParams.not.iterationId, + 'not[release_tag]': this.filterParams.not.releaseTag, }, undefined, ); @@ -205,11 +247,14 @@ export default { 'label_name[]': labelName, assignee_username: assigneeUsername, milestone_title: milestoneTitle, + iteration_id: iterationId, search, types, weight, - epic_id: getIdFromGraphQLId(epicId), + epic_id: isGid(epicId) ? getIdFromGraphQLId(epicId) : epicId, my_reaction_emoji: myReactionEmoji, + release_tag: releaseTag, + confidential, }; }, }, @@ -246,30 +291,39 @@ export default { filters.forEach((filter) => { switch (filter.type) { - case 'author_username': + case 'author': filterParams.authorUsername = filter.value.data; break; - case 'assignee_username': + case 'assignee': filterParams.assigneeUsername = filter.value.data; break; - case 'types': + case 'type': filterParams.types = filter.value.data; break; - case 'label_name': + case 'label': labels.push(filter.value.data); break; - case 'milestone_title': + case 'milestone': filterParams.milestoneTitle = filter.value.data; break; + case 'iteration': + filterParams.iterationId = filter.value.data; + break; case 'weight': filterParams.weight = filter.value.data; break; - case 'epic_id': + case 'epic': filterParams.epicId = filter.value.data; break; - case 'my_reaction_emoji': + case 'my-reaction': filterParams.myReactionEmoji = filter.value.data; break; + case 'release': + filterParams.releaseTag = filter.value.data; + break; + case 'confidential': + filterParams.confidential = filter.value.data; + break; case 'filtered-search-term': if (filter.value.data) plainText.push(filter.value.data); break; @@ -285,6 +339,7 @@ export default { if (plainText.length) { filterParams.search = plainText.join(' '); } + return filterParams; }, }, diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 47dffc985aa..e4c3c3206a8 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -6,6 +6,7 @@ import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_opt import { sprintf, __ } from '~/locale'; import defaultSortableConfig from '~/sortable/sortable_config'; import Tracking from '~/tracking'; +import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; import { toggleFormEventPrefix, DraggableItemTypes } from '../constants'; import eventHub from '../eventhub'; import BoardCard from './board_card.vue'; @@ -50,11 +51,22 @@ export default { showEpicForm: false, }; }, + apollo: { + boardList: { + query: listQuery, + variables() { + return { + id: this.list.id, + filters: this.filterParams, + }; + }, + }, + }, computed: { - ...mapState(['pageInfoByListId', 'listsFlags']), + ...mapState(['pageInfoByListId', 'listsFlags', 'filterParams']), ...mapGetters(['isEpicBoard']), listItemsCount() { - return this.isEpicBoard ? this.list.epicsCount : this.list.issuesCount; + return this.isEpicBoard ? this.list.epicsCount : this.boardList?.issuesCount; }, paginatedIssueText() { return sprintf(__('Showing %{pageSize} of %{total} %{issuableType}'), { diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index e985a368e64..19004518edf 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -17,6 +17,7 @@ import sidebarEventHub from '~/sidebar/event_hub'; import Tracking from '~/tracking'; import { formatDate } from '~/lib/utils/datetime_utility'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; import AccessorUtilities from '../../lib/utils/accessor'; import { inactiveId, LIST, ListType, toggleFormEventPrefix } from '../constants'; import eventHub from '../eventhub'; @@ -74,7 +75,7 @@ export default { }, }, computed: { - ...mapState(['activeId']), + ...mapState(['activeId', 'filterParams']), ...mapGetters(['isEpicBoard', 'isSwimlanesOn']), isLoggedIn() { return Boolean(this.currentUserId); @@ -119,14 +120,11 @@ export default { } return false; }, - itemsCount() { - return this.list.issuesCount; - }, countIcon() { return 'issues'; }, itemsTooltipLabel() { - return n__(`%d issue`, `%d issues`, this.itemsCount); + return n__(`%d issue`, `%d issues`, this.boardLists?.issuesCount); }, chevronTooltip() { return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse; @@ -158,6 +156,23 @@ export default { userCanDrag() { return !this.disabled && isListDraggable(this.list); }, + isLoading() { + return this.$apollo.queries.boardList.loading; + }, + }, + apollo: { + boardList: { + query: listQuery, + variables() { + return { + id: this.list.id, + filters: this.filterParams, + }; + }, + skip() { + return this.isEpicBoard; + }, + }, }, created() { const localCollapsed = parseBoolean(localStorage.getItem(`${this.uniqueKey}.collapsed`)); @@ -375,10 +390,10 @@ export default { </gl-sprintf> </div> <div v-else>• {{ itemsTooltipLabel }}</div> - <div v-if="weightFeatureAvailable"> + <div v-if="weightFeatureAvailable && !isLoading"> • <gl-sprintf :message="__('%{totalWeight} total weight')"> - <template #totalWeight>{{ list.totalWeight }}</template> + <template #totalWeight>{{ boardList.totalWeight }}</template> </gl-sprintf> </div> </gl-tooltip> @@ -396,14 +411,18 @@ export default { <gl-tooltip :target="() => $refs.itemCount" :title="itemsTooltipLabel" /> <span ref="itemCount" class="gl-display-inline-flex gl-align-items-center"> <gl-icon class="gl-mr-2" :name="countIcon" /> - <item-count :items-size="itemsCount" :max-issue-count="list.maxIssueCount" /> + <item-count + v-if="!isLoading" + :items-size="isEpicBoard ? list.epicsCount : boardList.issuesCount" + :max-issue-count="list.maxIssueCount" + /> </span> <!-- EE start --> - <template v-if="weightFeatureAvailable && !isEpicBoard"> + <template v-if="weightFeatureAvailable && !isEpicBoard && !isLoading"> <gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" /> <span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3"> <gl-icon class="gl-mr-2" name="weight" /> - {{ list.totalWeight }} + {{ boardList.totalWeight }} </span> </template> <!-- EE end --> diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 71facba1378..69343cd78d8 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -349,6 +349,9 @@ export default { v-if="showCreate" v-gl-modal-directive="'board-config-modal'" data-qa-selector="create_new_board_button" + data-track-action="click_button" + data-track-label="create_new_board" + data-track-property="dropdown" @click.prevent="showPage('new')" > {{ s__('IssueBoards|Create new board') }} diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue index bdb9c2be836..7fc87f9f672 100644 --- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue @@ -2,22 +2,25 @@ import { GlFilteredSearchToken } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { mapActions } from 'vuex'; +import { orderBy } from 'lodash'; import BoardFilteredSearch from 'ee_else_ce/boards/components/board_filtered_search.vue'; import { BoardType } from '~/boards/constants'; import axios from '~/lib/utils/axios_utils'; +import { joinPaths } from '~/lib/utils/url_utility'; import issueBoardFilters from '~/boards/issue_board_filters'; import { TYPE_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import { - DEFAULT_MILESTONES_GRAPHQL, TOKEN_TITLE_MY_REACTION, + OPERATOR_IS_AND_IS_NOT, + OPERATOR_IS_ONLY, } from '~/vue_shared/components/filtered_search_bar/constants'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; -import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue'; +import ReleaseToken from '~/vue_shared/components/filtered_search_bar/tokens/release_token.vue'; export default { types: { @@ -34,12 +37,11 @@ export default { incident: __('Incident'), issue: __('Issue'), milestone: __('Milestone'), - weight: __('Weight'), - is: __('is'), - isNot: __('is not'), + release: __('Release'), + confidential: __('Confidential'), }, components: { BoardFilteredSearch }, - inject: ['isSignedIn'], + inject: ['isSignedIn', 'releasesFetchPath'], props: { fullPath: { type: String, @@ -62,15 +64,14 @@ export default { tokensCE() { const { label, - is, - isNot, author, assignee, issue, incident, type, milestone, - weight, + release, + confidential, } = this.$options.i18n; const { types } = this.$options; const { fetchAuthors, fetchLabels } = issueBoardFilters( @@ -79,15 +80,12 @@ export default { this.boardType, ); - return [ + const tokens = [ { icon: 'user', title: assignee, - type: 'assignee_username', - operators: [ - { value: '=', description: is }, - { value: '!=', description: isNot }, - ], + type: 'assignee', + operators: OPERATOR_IS_AND_IS_NOT, token: AuthorToken, unique: true, fetchAuthors, @@ -96,11 +94,8 @@ export default { { icon: 'pencil', title: author, - type: 'author_username', - operators: [ - { value: '=', description: is }, - { value: '!=', description: isNot }, - ], + type: 'author', + operators: OPERATOR_IS_AND_IS_NOT, symbol: '@', token: AuthorToken, unique: true, @@ -110,11 +105,8 @@ export default { { icon: 'labels', title: label, - type: 'label_name', - operators: [ - { value: '=', description: is }, - { value: '!=', description: isNot }, - ], + type: 'label', + operators: OPERATOR_IS_AND_IS_NOT, token: LabelToken, unique: false, symbol: '~', @@ -123,7 +115,7 @@ export default { ...(this.isSignedIn ? [ { - type: 'my_reaction_emoji', + type: 'my-reaction', title: TOKEN_TITLE_MY_REACTION, icon: 'thumb-up', token: EmojiToken, @@ -144,22 +136,33 @@ export default { }); }, }, + { + type: 'confidential', + icon: 'eye-slash', + title: confidential, + unique: true, + token: GlFilteredSearchToken, + operators: OPERATOR_IS_ONLY, + options: [ + { icon: 'eye-slash', value: 'yes', title: __('Yes') }, + { icon: 'eye', value: 'no', title: __('No') }, + ], + }, ] : []), { - type: 'milestone_title', + type: 'milestone', title: milestone, icon: 'clock', symbol: '%', token: MilestoneToken, unique: true, - defaultMilestones: DEFAULT_MILESTONES_GRAPHQL, fetchMilestones: this.fetchMilestones, }, { icon: 'issues', title: type, - type: 'types', + type: 'type', token: GlFilteredSearchToken, unique: true, options: [ @@ -168,13 +171,27 @@ export default { ], }, { - type: 'weight', - title: weight, - icon: 'weight', - token: WeightToken, - unique: true, + type: 'release', + title: release, + icon: 'rocket', + token: ReleaseToken, + fetchReleases: (search) => { + // TODO: Switch to GraphQL query when backend is ready: https://gitlab.com/gitlab-org/gitlab/-/issues/337686 + return axios + .get(joinPaths(gon.relative_url_root, this.releasesFetchPath)) + .then(({ data }) => { + if (search) { + return fuzzaldrinPlus.filter(data, search, { + key: ['tag'], + }); + } + return data; + }); + }, }, ]; + + return orderBy(tokens, ['title']); }, tokens() { return this.tokensCE; diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue deleted file mode 100644 index ec53947fd5f..00000000000 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue +++ /dev/null @@ -1,173 +0,0 @@ -<script> -import { GlLabel } from '@gitlab/ui'; -import { mapGetters, mapActions } from 'vuex'; -import Api from '~/api'; -import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { isScopedLabel } from '~/lib/utils/common_utils'; -import { mergeUrlParams } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; -import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; - -export default { - components: { - BoardEditableItem, - LabelsSelect, - GlLabel, - }, - inject: { - labelsFetchPath: { - default: null, - }, - labelsManagePath: {}, - labelsFilterBasePath: {}, - }, - data() { - return { - loading: false, - oldIid: null, - isEditing: false, - }; - }, - computed: { - ...mapGetters(['activeBoardItem', 'projectPathForActiveIssue']), - selectedLabels() { - const { labels = [] } = this.activeBoardItem; - - return labels.map((label) => ({ - ...label, - id: getIdFromGraphQLId(label.id), - })); - }, - issueLabels() { - const { labels = [] } = this.activeBoardItem; - - return labels.map((label) => ({ - ...label, - scoped: isScopedLabel(label), - })); - }, - fetchPath() { - /* - Labels fetched in epic boards are always group-level labels - and the correct path are passed from the backend (injected through labelsFetchPath) - - For issue boards, we should always include project-level labels and use a different endpoint. - (it requires knowing the project path of a selected issue.) - - Note 1. that we will be using GraphQL to fetch labels when we create a labels select widget. - And this component will be removed _wholesale_ https://gitlab.com/gitlab-org/gitlab/-/issues/300653. - - Note 2. Moreover, 'fetchPath' needs to be used as a key for 'labels-select' component to force updates. - 'labels-select' has its own vuex store and initializes the passed props as states - and these states aren't reactively bound to the passed props. - */ - - const projectLabelsFetchPath = mergeUrlParams( - { include_ancestor_groups: true }, - Api.buildUrl(Api.projectLabelsPath).replace( - ':namespace_path/:project_path', - this.projectPathForActiveIssue, - ), - ); - - return this.labelsFetchPath || projectLabelsFetchPath; - }, - }, - watch: { - activeBoardItem(_, oldVal) { - if (this.isEditing) { - this.oldIid = oldVal.iid; - } else { - this.oldIid = null; - } - }, - }, - methods: { - ...mapActions(['setActiveBoardItemLabels', 'setError']), - async setLabels(payload) { - this.loading = true; - this.$refs.sidebarItem.collapse(); - - try { - const addLabelIds = payload.filter((label) => label.set).map((label) => label.id); - const removeLabelIds = payload.filter((label) => !label.set).map((label) => label.id); - - const input = { - addLabelIds, - removeLabelIds, - projectPath: this.projectPathForActiveIssue, - iid: this.oldIid, - }; - await this.setActiveBoardItemLabels(input); - this.oldIid = null; - } catch (e) { - this.setError({ error: e, message: __('An error occurred while updating labels.') }); - } finally { - this.loading = false; - } - }, - async removeLabel(id) { - this.loading = true; - - try { - const removeLabelIds = [getIdFromGraphQLId(id)]; - const input = { removeLabelIds, projectPath: this.projectPathForActiveIssue }; - await this.setActiveBoardItemLabels(input); - } catch (e) { - this.setError({ error: e, message: __('An error occurred when removing the label.') }); - } finally { - this.loading = false; - } - }, - }, -}; -</script> - -<template> - <board-editable-item - ref="sidebarItem" - :title="__('Labels')" - :loading="loading" - data-testid="sidebar-labels" - @open="isEditing = true" - @close="isEditing = false" - > - <template #collapsed> - <gl-label - v-for="label in issueLabels" - :key="label.id" - :background-color="label.color" - :title="label.title" - :description="label.description" - :scoped="label.scoped" - :show-close-button="true" - :disabled="loading" - class="gl-mr-2 gl-mb-2" - @close="removeLabel(label.id)" - /> - </template> - <template #default="{ edit }"> - <labels-select - ref="labelsSelect" - :key="fetchPath" - :allow-label-edit="false" - :allow-label-create="false" - :allow-multiselect="true" - :allow-scoped-labels="true" - :selected-labels="selectedLabels" - :labels-fetch-path="fetchPath" - :labels-manage-path="labelsManagePath" - :labels-filter-base-path="labelsFilterBasePath" - :labels-list-title="__('Select label')" - :dropdown-button-text="__('Choose labels')" - :is-editing="edit" - variant="sidebar" - class="gl-display-block labels gl-w-full" - @updateSelectedLabels="setLabels" - > - {{ __('None') }} - </labels-select> - </template> - </board-editable-item> -</template> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue deleted file mode 100644 index 4f5c55d0c5d..00000000000 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue +++ /dev/null @@ -1,75 +0,0 @@ -<script> -import { GlToggle } from '@gitlab/ui'; -import { mapGetters, mapActions } from 'vuex'; -import { __, s__ } from '~/locale'; - -export default { - i18n: { - header: { - title: __('Notifications'), - /* Any change to subscribeDisabledDescription - must be reflected in app/helpers/notifications_helper.rb */ - subscribeDisabledDescription: __( - 'Notifications have been disabled by the project or group owner', - ), - }, - updateSubscribedErrorMessage: s__( - 'IssueBoards|An error occurred while setting notifications status. Please try again.', - ), - }, - components: { - GlToggle, - }, - inject: ['emailsDisabled'], - data() { - return { - loading: false, - }; - }, - computed: { - ...mapGetters(['activeBoardItem', 'projectPathForActiveIssue', 'isEpicBoard']), - isEmailsDisabled() { - return this.isEpicBoard ? this.emailsDisabled : this.activeBoardItem.emailsDisabled; - }, - notificationText() { - return this.isEmailsDisabled - ? this.$options.i18n.header.subscribeDisabledDescription - : this.$options.i18n.header.title; - }, - }, - methods: { - ...mapActions(['setActiveItemSubscribed', 'setError']), - async handleToggleSubscription() { - this.loading = true; - try { - await this.setActiveItemSubscribed({ - subscribed: !this.activeBoardItem.subscribed, - projectPath: this.projectPathForActiveIssue, - }); - } catch (error) { - this.setError({ error, message: this.$options.i18n.updateSubscribedErrorMessage }); - } finally { - this.loading = false; - } - }, - }, -}; -</script> - -<template> - <div - class="gl-display-flex gl-align-items-center gl-justify-content-space-between" - data-testid="sidebar-notifications" - > - <span data-testid="notification-header-text"> {{ notificationText }} </span> - <gl-toggle - v-if="!isEmailsDisabled" - :value="activeBoardItem.subscribed" - :is-loading="loading" - :label="$options.i18n.header.title" - label-position="hidden" - data-testid="notification-subscribe-toggle" - @change="handleToggleSubscription" - /> - </div> -</template> diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 391e0d1fb0a..851b5eca40d 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -104,8 +104,10 @@ export const FilterFields = { 'assigneeUsername', 'assigneeWildcardId', 'authorUsername', + 'confidential', 'labelName', 'milestoneTitle', + 'milestoneWildcardId', 'myReactionEmoji', 'releaseTag', 'search', @@ -114,6 +116,18 @@ export const FilterFields = { ], }; +/* eslint-disable @gitlab/require-i18n-strings */ +export const AssigneeFilterType = { + any: 'Any', +}; + +export const MilestoneFilterType = { + any: 'Any', + none: 'None', + started: 'Started', + upcoming: 'Upcoming', +}; + export const DraggableItemTypes = { card: 'card', list: 'list', diff --git a/app/assets/javascripts/boards/graphql/board_labels.query.graphql b/app/assets/javascripts/boards/graphql/board_labels.query.graphql index b19a24e8808..525a4863379 100644 --- a/app/assets/javascripts/boards/graphql/board_labels.query.graphql +++ b/app/assets/javascripts/boards/graphql/board_labels.query.graphql @@ -7,6 +7,7 @@ query BoardLabels( $isProject: Boolean = false ) { group(fullPath: $fullPath) @include(if: $isGroup) { + id labels(searchTerm: $searchTerm, onlyGroupLabels: true, includeAncestorGroups: true) { nodes { ...Label @@ -14,6 +15,7 @@ query BoardLabels( } } project(fullPath: $fullPath) @include(if: $isProject) { + id labels(searchTerm: $searchTerm, includeAncestorGroups: true) { nodes { ...Label diff --git a/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql index 0e1d11727cf..81cc7b4d246 100644 --- a/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql @@ -2,6 +2,8 @@ mutation createBoardList($boardId: BoardID!, $backlog: Boolean, $labelId: LabelID) { boardListCreate(input: { boardId: $boardId, backlog: $backlog, labelId: $labelId }) { + # We have ID in a deeply nested fragment + # eslint-disable-next-line @graphql-eslint/require-id-when-available list { ...BoardListFragment } diff --git a/app/assets/javascripts/boards/graphql/board_list_shared.fragment.graphql b/app/assets/javascripts/boards/graphql/board_list_shared.fragment.graphql index d85b736720b..5b532906f6a 100644 --- a/app/assets/javascripts/boards/graphql/board_list_shared.fragment.graphql +++ b/app/assets/javascripts/boards/graphql/board_list_shared.fragment.graphql @@ -4,7 +4,6 @@ fragment BoardListShared on BoardList { position listType collapsed - issuesCount label { id title diff --git a/app/assets/javascripts/boards/graphql/board_list_update.mutation.graphql b/app/assets/javascripts/boards/graphql/board_list_update.mutation.graphql index b474c9acb93..7ea0e2f915a 100644 --- a/app/assets/javascripts/boards/graphql/board_list_update.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/board_list_update.mutation.graphql @@ -2,6 +2,8 @@ mutation UpdateBoardList($listId: ID!, $position: Int, $collapsed: Boolean) { updateBoardList(input: { listId: $listId, position: $position, collapsed: $collapsed }) { + # We have ID in a deeply nested fragment + # eslint-disable-next-line @graphql-eslint/require-id-when-available list { ...BoardListFragment } diff --git a/app/assets/javascripts/boards/graphql/board_lists.query.graphql b/app/assets/javascripts/boards/graphql/board_lists.query.graphql index 47e87907d76..e6e98864aad 100644 --- a/app/assets/javascripts/boards/graphql/board_lists.query.graphql +++ b/app/assets/javascripts/boards/graphql/board_lists.query.graphql @@ -8,9 +8,13 @@ query BoardLists( $isProject: Boolean = false ) { group(fullPath: $fullPath) @include(if: $isGroup) { + id board(id: $boardId) { + id hideBacklogList lists(issueFilters: $filters) { + # We have ID in a deeply nested fragment + # eslint-disable-next-line @graphql-eslint/require-id-when-available nodes { ...BoardListFragment } @@ -18,9 +22,13 @@ query BoardLists( } } project(fullPath: $fullPath) @include(if: $isProject) { + id board(id: $boardId) { + id hideBacklogList lists(issueFilters: $filters) { + # We have ID in a deeply nested fragment + # eslint-disable-next-line @graphql-eslint/require-id-when-available nodes { ...BoardListFragment } diff --git a/app/assets/javascripts/boards/graphql/board_lists_deferred.query.graphql b/app/assets/javascripts/boards/graphql/board_lists_deferred.query.graphql new file mode 100644 index 00000000000..bae3220dfad --- /dev/null +++ b/app/assets/javascripts/boards/graphql/board_lists_deferred.query.graphql @@ -0,0 +1,6 @@ +query BoardList($id: ID!, $filters: BoardIssueInput) { + boardList(id: $id, issueFilters: $filters) { + id + issuesCount + } +} diff --git a/app/assets/javascripts/boards/graphql/group_board.query.graphql b/app/assets/javascripts/boards/graphql/group_board.query.graphql index 77c8e0378f0..8d87b83da96 100644 --- a/app/assets/javascripts/boards/graphql/group_board.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_board.query.graphql @@ -2,6 +2,7 @@ query GroupBoard($fullPath: ID!, $boardId: ID!) { workspace: group(fullPath: $fullPath) { + id board(id: $boardId) { ...BoardScopeFragment } diff --git a/app/assets/javascripts/boards/graphql/group_board_members.query.graphql b/app/assets/javascripts/boards/graphql/group_board_members.query.graphql index d3251c2aa12..aec674eb006 100644 --- a/app/assets/javascripts/boards/graphql/group_board_members.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_board_members.query.graphql @@ -3,6 +3,7 @@ query GroupBoardMembers($fullPath: ID!, $search: String) { workspace: group(fullPath: $fullPath) { __typename + id assignees: groupMembers(search: $search, relations: [DIRECT, DESCENDANTS, INHERITED]) { __typename nodes { diff --git a/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql b/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql index 73aa9137dec..0963b3fbfaa 100644 --- a/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql @@ -1,5 +1,6 @@ query GroupBoardMilestones($fullPath: ID!, $searchTerm: String) { group(fullPath: $fullPath) { + id milestones(includeAncestors: true, searchTitle: $searchTerm) { nodes { id diff --git a/app/assets/javascripts/boards/graphql/group_boards.query.graphql b/app/assets/javascripts/boards/graphql/group_boards.query.graphql index feafd6ae10d..0823c4f5a83 100644 --- a/app/assets/javascripts/boards/graphql/group_boards.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_boards.query.graphql @@ -2,6 +2,7 @@ query group_boards($fullPath: ID!) { group(fullPath: $fullPath) { + id boards { edges { node { diff --git a/app/assets/javascripts/boards/graphql/group_projects.query.graphql b/app/assets/javascripts/boards/graphql/group_projects.query.graphql index c5732bbaff3..0da14d0b872 100644 --- a/app/assets/javascripts/boards/graphql/group_projects.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_projects.query.graphql @@ -2,6 +2,7 @@ query boardsGetGroupProjects($fullPath: ID!, $search: String, $after: String) { group(fullPath: $fullPath) { + id projects(search: $search, after: $after, first: 100, includeSubgroups: true) { nodes { id diff --git a/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql index 70eb1dfbf7e..c9c5d744371 100644 --- a/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql @@ -1,13 +1,12 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" + mutation issueSetLabels($input: UpdateIssueInput!) { - updateIssue(input: $input) { - issue { + updateIssuableLabels: updateIssue(input: $input) { + issuable: issue { id labels { nodes { - id - title - color - description + ...Label } } } diff --git a/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql index bfb87758e17..c130a64cac4 100644 --- a/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql @@ -1,6 +1,7 @@ mutation issueSetSubscription($input: IssueSetSubscriptionInput!) { updateIssuableSubscription: issueSetSubscription(input: $input) { issue { + id subscribed } errors diff --git a/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql index 6ad12d982e0..147cf040a85 100644 --- a/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql @@ -1,6 +1,7 @@ mutation issueSetTitle($input: UpdateIssueInput!) { updateIssuableTitle: updateIssue(input: $input) { issue { + id title } errors diff --git a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql index 9f93bc6d5bf..105f2931caa 100644 --- a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql +++ b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql @@ -1,6 +1,6 @@ #import "ee_else_ce/boards/graphql/issue.fragment.graphql" -query BoardListEE( +query BoardListsEE( $fullPath: ID! $boardId: ID! $id: ID @@ -11,7 +11,9 @@ query BoardListEE( $first: Int ) { group(fullPath: $fullPath) @include(if: $isGroup) { + id board(id: $boardId) { + id lists(id: $id, issueFilters: $filters) { nodes { id @@ -33,7 +35,9 @@ query BoardListEE( } } project(fullPath: $fullPath) @include(if: $isProject) { + id board(id: $boardId) { + id lists(id: $id, issueFilters: $filters) { nodes { id diff --git a/app/assets/javascripts/boards/graphql/project_board.query.graphql b/app/assets/javascripts/boards/graphql/project_board.query.graphql index 6e4cd6bed57..8246d615a6a 100644 --- a/app/assets/javascripts/boards/graphql/project_board.query.graphql +++ b/app/assets/javascripts/boards/graphql/project_board.query.graphql @@ -2,6 +2,7 @@ query ProjectBoard($fullPath: ID!, $boardId: ID!) { workspace: project(fullPath: $fullPath) { + id board(id: $boardId) { ...BoardScopeFragment } diff --git a/app/assets/javascripts/boards/graphql/project_board_members.query.graphql b/app/assets/javascripts/boards/graphql/project_board_members.query.graphql index fc6cc6b832c..45bec5e574b 100644 --- a/app/assets/javascripts/boards/graphql/project_board_members.query.graphql +++ b/app/assets/javascripts/boards/graphql/project_board_members.query.graphql @@ -3,6 +3,7 @@ query ProjectBoardMembers($fullPath: ID!, $search: String) { workspace: project(fullPath: $fullPath) { __typename + id assignees: projectMembers(search: $search) { __typename nodes { diff --git a/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql b/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql index 8dd4d256caa..e456823d78a 100644 --- a/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql +++ b/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql @@ -1,5 +1,6 @@ query ProjectBoardMilestones($fullPath: ID!, $searchTerm: String) { project(fullPath: $fullPath) { + id milestones(searchTitle: $searchTerm, includeAncestors: true) { nodes { id diff --git a/app/assets/javascripts/boards/graphql/project_boards.query.graphql b/app/assets/javascripts/boards/graphql/project_boards.query.graphql index f98d25ba671..b8879bc260c 100644 --- a/app/assets/javascripts/boards/graphql/project_boards.query.graphql +++ b/app/assets/javascripts/boards/graphql/project_boards.query.graphql @@ -2,6 +2,7 @@ query project_boards($fullPath: ID!) { project(fullPath: $fullPath) { + id boards { edges { node { diff --git a/app/assets/javascripts/boards/graphql/project_milestones.query.graphql b/app/assets/javascripts/boards/graphql/project_milestones.query.graphql index 61c9ddded9b..4c952096d76 100644 --- a/app/assets/javascripts/boards/graphql/project_milestones.query.graphql +++ b/app/assets/javascripts/boards/graphql/project_milestones.query.graphql @@ -5,6 +5,7 @@ query boardProjectMilestones( $searchTitle: String ) { project(fullPath: $fullPath) { + id milestones(state: $state, includeAncestors: $includeAncestors, searchTitle: $searchTitle) { edges { node { diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 6fa8dd63245..ded3bfded86 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -110,7 +110,8 @@ export default () => { }); if (gon?.features?.issueBoardsFilteredSearch) { - initBoardsFilteredSearch(apolloProvider, isLoggedIn()); + const { releasesFetchPath } = $boardApp.dataset; + initBoardsFilteredSearch(apolloProvider, isLoggedIn(), releasesFetchPath); } mountBoardApp($boardApp); diff --git a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js index 1ea74d5685c..a8ade58e316 100644 --- a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js +++ b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js @@ -4,7 +4,7 @@ import store from '~/boards/stores'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { queryToObject } from '~/lib/utils/url_utility'; -export default (apolloProvider, isSignedIn) => { +export default (apolloProvider, isSignedIn, releasesFetchPath) => { const el = document.getElementById('js-issue-board-filtered-search'); const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true }); @@ -21,6 +21,7 @@ export default (apolloProvider, isSignedIn) => { provide: { initialFilterParams, isSignedIn, + releasesFetchPath, }, store, // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/324094 apolloProvider, diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 3a96e535cf7..1ebfcfc331b 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -16,30 +16,30 @@ import { ListTypeTitles, DraggableItemTypes, } from 'ee_else_ce/boards/constants'; -import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql'; -import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { queryToObject } from '~/lib/utils/url_utility'; -import { s__ } from '~/locale'; import { + formatIssueInput, formatBoardLists, formatListIssues, formatListsPageInfo, formatIssue, - formatIssueInput, updateListPosition, moveItemListHelper, getMoveData, FiltersInfo, filterVariables, -} from '../boards_util'; +} from 'ee_else_ce/boards/boards_util'; +import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql'; +import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql'; +import totalCountAndWeightQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { queryToObject } from '~/lib/utils/url_utility'; +import { s__ } from '~/locale'; import { gqlClient } from '../graphql'; import boardLabelsQuery from '../graphql/board_labels.query.graphql'; import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql'; import groupProjectsQuery from '../graphql/group_projects.query.graphql'; import issueCreateMutation from '../graphql/issue_create.mutation.graphql'; -import issueSetLabelsMutation from '../graphql/issue_set_labels.mutation.graphql'; import listsIssuesQuery from '../graphql/lists_issues.query.graphql'; import projectBoardMilestonesQuery from '../graphql/project_board_milestones.query.graphql'; @@ -373,7 +373,6 @@ export default { commit(types.REQUEST_ITEMS_FOR_LIST, { listId, fetchNext }); const { fullPath, fullBoardId, boardType, filterParams } = state; - const variables = { fullPath, boardId: fullBoardId, @@ -503,9 +502,10 @@ export default { updateIssueOrder: async ({ commit, dispatch, state }, { moveData, mutationVariables = {} }) => { try { - const { itemId, fromListId, toListId, moveBeforeId, moveAfterId } = moveData; + const { itemId, fromListId, toListId, moveBeforeId, moveAfterId, itemNotInToList } = moveData; const { fullBoardId, + filterParams, boardItems: { [itemId]: { iid, referencePath }, }, @@ -524,6 +524,67 @@ export default { // 'mutationVariables' allows EE code to pass in extra parameters. ...mutationVariables, }, + update( + cache, + { + data: { + issueMoveList: { + issue: { weight }, + }, + }, + }, + ) { + if (fromListId === toListId) return; + + const updateFromList = () => { + const fromList = cache.readQuery({ + query: totalCountAndWeightQuery, + variables: { id: fromListId, filters: filterParams }, + }); + + const updatedFromList = { + boardList: { + __typename: 'BoardList', + id: fromList.boardList.id, + issuesCount: fromList.boardList.issuesCount - 1, + totalWeight: fromList.boardList.totalWeight - Number(weight), + }, + }; + + cache.writeQuery({ + query: totalCountAndWeightQuery, + variables: { id: fromListId, filters: filterParams }, + data: updatedFromList, + }); + }; + + const updateToList = () => { + if (!itemNotInToList) return; + + const toList = cache.readQuery({ + query: totalCountAndWeightQuery, + variables: { id: toListId, filters: filterParams }, + }); + + const updatedToList = { + boardList: { + __typename: 'BoardList', + id: toList.boardList.id, + issuesCount: toList.boardList.issuesCount + 1, + totalWeight: toList.boardList.totalWeight + Number(weight), + }, + }; + + cache.writeQuery({ + query: totalCountAndWeightQuery, + variables: { id: toListId, filters: filterParams }, + data: updatedToList, + }); + }; + + updateFromList(); + updateToList(); + }, }); if (data?.issueMoveList?.errors.length || !data.issueMoveList) { @@ -567,7 +628,7 @@ export default { }, addListNewIssue: ( - { state: { boardConfig, boardType, fullPath }, dispatch, commit }, + { state: { boardConfig, boardType, fullPath, filterParams }, dispatch, commit }, { issueInput, list, placeholderId = `tmp-${new Date().getTime()}` }, ) => { const input = formatIssueInput(issueInput, boardConfig); @@ -583,6 +644,27 @@ export default { .mutate({ mutation: issueCreateMutation, variables: { input }, + update(cache) { + const fromList = cache.readQuery({ + query: totalCountAndWeightQuery, + variables: { id: list.id, filters: filterParams }, + }); + + const updatedList = { + boardList: { + __typename: 'BoardList', + id: fromList.boardList.id, + issuesCount: fromList.boardList.issuesCount + 1, + totalWeight: fromList.boardList.totalWeight, + }, + }; + + cache.writeQuery({ + query: totalCountAndWeightQuery, + variables: { id: list.id, filters: filterParams }, + data: updatedList, + }); + }, }) .then(({ data }) => { if (data.createIssue.errors.length) { @@ -610,33 +692,6 @@ export default { setActiveIssueLabels: async ({ commit, getters }, input) => { const { activeBoardItem } = getters; - if (!gon.features?.labelsWidget) { - const { data } = await gqlClient.mutate({ - mutation: issueSetLabelsMutation, - variables: { - input: { - iid: input.iid || String(activeBoardItem.iid), - labelIds: input.labelsId ?? undefined, - addLabelIds: input.addLabelIds ?? [], - removeLabelIds: input.removeLabelIds ?? [], - projectPath: input.projectPath, - }, - }, - }); - - if (data.updateIssue?.errors?.length > 0) { - throw new Error(data.updateIssue.errors); - } - - commit(types.UPDATE_BOARD_ITEM_BY_ID, { - itemId: data.updateIssue?.issue?.id || activeBoardItem.id, - prop: 'labels', - value: data.updateIssue?.issue?.labels.nodes, - }); - - return; - } - let labels = input?.labels || []; if (input.removeLabelIds) { labels = activeBoardItem.labels.filter( diff --git a/app/assets/javascripts/ci_lint/components/ci_lint.vue b/app/assets/javascripts/ci_lint/components/ci_lint.vue index bc8a1f05ef5..d541e89756a 100644 --- a/app/assets/javascripts/ci_lint/components/ci_lint.vue +++ b/app/assets/javascripts/ci_lint/components/ci_lint.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlFormCheckbox, GlIcon, GlLink, GlAlert } from '@gitlab/ui'; import CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue'; -import lintCiMutation from '~/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql'; +import lintCiMutation from '~/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql'; import SourceEditor from '~/vue_shared/components/source_editor.vue'; export default { diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue index 77ec1f1af47..4ab9b36058d 100644 --- a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue +++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue @@ -3,7 +3,7 @@ import { GlTable, GlButton, GlBadge, GlTooltipDirective } from '@gitlab/ui'; import { s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; export default { diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue index 8e527e2bff6..e630ce71bd3 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue @@ -17,6 +17,7 @@ import { import Cookies from 'js-cookie'; import { mapActions, mapState } from 'vuex'; import { __ } from '~/locale'; +import Tracking from '~/tracking'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { mapComputed } from '~/vuex_shared/bindings'; import { @@ -25,10 +26,14 @@ import { AWS_TIP_DISMISSED_COOKIE_NAME, AWS_TIP_MESSAGE, CONTAINS_VARIABLE_REFERENCE_MESSAGE, + EVENT_LABEL, + EVENT_ACTION, } from '../constants'; import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens'; +const trackingMixin = Tracking.mixin({ label: EVENT_LABEL }); + export default { modalId: ADD_CI_VARIABLE_MODAL_ID, tokens: awsTokens, @@ -51,10 +56,11 @@ export default { GlModal, GlSprintf, }, - mixins: [glFeatureFlagsMixin()], + mixins: [glFeatureFlagsMixin(), trackingMixin], data() { return { isTipDismissed: Cookies.get(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true', + validationErrorEventProperty: '', }; }, computed: { @@ -147,6 +153,14 @@ export default { return this.variable.secret_value === '' || (this.tokenValidationState && this.maskedState); }, }, + watch: { + variable: { + handler() { + this.trackVariableValidationErrors(); + }, + deep: true, + }, + }, methods: { ...mapActions([ 'addVariable', @@ -179,6 +193,7 @@ export default { this.clearModal(); this.resetSelectedEnvironment(); + this.resetValidationErrorEvents(); }, updateOrAddVariable() { if (this.variableBeingEdited) { @@ -193,6 +208,31 @@ export default { this.setVariableProtected(); } }, + trackVariableValidationErrors() { + const property = this.getTrackingErrorProperty(); + if (!this.validationErrorEventProperty && property) { + this.track(EVENT_ACTION, { property }); + this.validationErrorEventProperty = property; + } + }, + getTrackingErrorProperty() { + let property; + if (this.variable.secret_value?.length && !property) { + if (this.displayMaskedError && this.maskableRegex?.length) { + const supportedChars = this.maskableRegex.replace('^', '').replace(/{(\d,)}\$/, ''); + const regex = new RegExp(supportedChars, 'g'); + property = this.variable.secret_value.replace(regex, ''); + } + if (this.containsVariableReference) { + property = '$'; + } + } + + return property; + }, + resetValidationErrorEvents() { + this.validationErrorEventProperty = ''; + }, }, }; </script> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue index b959d97daea..9c0ffab7f6b 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue @@ -1,5 +1,5 @@ <script> -import { GlTable, GlButton, GlModalDirective, GlIcon } from '@gitlab/ui'; +import { GlTable, GlButton, GlModalDirective, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import { s__, __ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -59,6 +59,7 @@ export default { }, directives: { GlModalDirective, + GlTooltip: GlTooltipDirective, }, mixins: [glFeatureFlagsMixin()], computed: { @@ -102,27 +103,38 @@ export default { <col v-for="field in scope.fields" :key="field.key" :style="field.customStyle" /> </template> <template #cell(key)="{ item }"> - <div class="d-flex truncated-container"> - <span :id="`ci-variable-key-${item.id}`" class="d-inline-block mw-100 text-truncate">{{ - item.key - }}</span> - <ci-variable-popover - :target="`ci-variable-key-${item.id}`" - :value="item.key" - :tooltip-text="__('Copy key')" + <div class="gl-display-flex truncated-container gl-align-items-center"> + <span + :id="`ci-variable-key-${item.id}`" + class="gl-display-inline-block gl-max-w-full gl-text-truncate" + >{{ item.key }}</span + > + <gl-button + v-gl-tooltip + category="tertiary" + icon="copy-to-clipboard" + :title="__('Copy key')" + :data-clipboard-text="item.key" + :aria-label="__('Copy to clipboard')" /> </div> </template> <template #cell(value)="{ item }"> - <span v-if="valuesHidden">*********************</span> - <div v-else class="d-flex truncated-container"> - <span :id="`ci-variable-value-${item.id}`" class="d-inline-block mw-100 text-truncate">{{ - item.value - }}</span> - <ci-variable-popover - :target="`ci-variable-value-${item.id}`" - :value="item.value" - :tooltip-text="__('Copy value')" + <div class="gl-display-flex gl-align-items-center truncated-container"> + <span v-if="valuesHidden">*********************</span> + <span + v-else + :id="`ci-variable-value-${item.id}`" + class="gl-display-inline-block gl-max-w-full gl-text-truncate" + >{{ item.value }}</span + > + <gl-button + v-gl-tooltip + category="tertiary" + icon="copy-to-clipboard" + :title="__('Copy value')" + :data-clipboard-text="item.value" + :aria-label="__('Copy to clipboard')" /> </div> </template> diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js index 4ebbf05814b..663a912883b 100644 --- a/app/assets/javascripts/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci_variable_list/constants.js @@ -19,6 +19,9 @@ export const AWS_TIP_MESSAGE = __( '%{deployLinkStart}Use a template to deploy to ECS%{deployLinkEnd}, or use a docker image to %{commandsLinkStart}run AWS commands in GitLab CI/CD%{commandsLinkEnd}.', ); +export const EVENT_LABEL = 'ci_variable_modal'; +export const EVENT_ACTION = 'validation_error'; + // AWS TOKEN CONSTANTS export const AWS_ACCESS_KEY_ID = 'AWS_ACCESS_KEY_ID'; export const AWS_DEFAULT_REGION = 'AWS_DEFAULT_REGION'; diff --git a/app/assets/javascripts/clusters/agents/components/activity_events_list.vue b/app/assets/javascripts/clusters/agents/components/activity_events_list.vue new file mode 100644 index 00000000000..6567ce203bc --- /dev/null +++ b/app/assets/javascripts/clusters/agents/components/activity_events_list.vue @@ -0,0 +1,176 @@ +<script> +import { + GlLoadingIcon, + GlEmptyState, + GlLink, + GlIcon, + GlAlert, + GlTooltipDirective, +} from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { n__, s__, __ } from '~/locale'; +import { formatDate, getDayDifference, isToday } from '~/lib/utils/datetime_utility'; +import { EVENTS_STORED_DAYS } from '../constants'; +import getAgentActivityEventsQuery from '../graphql/queries/get_agent_activity_events.query.graphql'; +import ActivityHistoryItem from './activity_history_item.vue'; + +export default { + components: { + GlLoadingIcon, + GlEmptyState, + GlAlert, + GlLink, + GlIcon, + ActivityHistoryItem, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + i18n: { + emptyText: s__( + 'ClusterAgents|See Agent activity updates such as tokens created or revoked and clusters connected or not connected.', + ), + emptyTooltip: s__('ClusterAgents|What is GitLab Agent activity?'), + error: s__( + 'ClusterAgents|An error occurred while retrieving GitLab Agent activity. Reload the page to try again.', + ), + today: __('Today'), + yesterday: __('Yesterday'), + }, + emptyHelpLink: helpPagePath('user/clusters/agent/install/index', { + anchor: 'view-agent-activity', + }), + borderClasses: 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100', + apollo: { + agentEvents: { + query: getAgentActivityEventsQuery, + variables() { + return { + agentName: this.agentName, + projectPath: this.projectPath, + }; + }, + update: (data) => data?.project?.clusterAgent?.activityEvents?.nodes, + error() { + this.isError = true; + }, + }, + }, + inject: ['agentName', 'projectPath', 'activityEmptyStateImage'], + data() { + return { + isError: false, + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.agentEvents?.loading; + }, + emptyStateTitle() { + return n__( + "ClusterAgents|There's no activity from the past day", + "ClusterAgents|There's no activity from the past %d days", + EVENTS_STORED_DAYS, + ); + }, + eventsList() { + const list = this.agentEvents; + const listByDates = {}; + + if (!list?.length) { + return listByDates; + } + + list.forEach((event) => { + const dateName = this.getFormattedDate(event.recordedAt); + if (!listByDates[dateName]) { + listByDates[dateName] = []; + } + listByDates[dateName].push(event); + }); + + return listByDates; + }, + hasEvents() { + return Object.keys(this.eventsList).length; + }, + }, + methods: { + isYesterday(date) { + const today = new Date(); + return getDayDifference(today, date) === -1; + }, + getFormattedDate(dateString) { + const date = new Date(dateString); + let dateName; + if (isToday(date)) { + dateName = this.$options.i18n.today; + } else if (this.isYesterday(date)) { + dateName = this.$options.i18n.yesterday; + } else { + dateName = formatDate(date, 'yyyy-mm-dd'); + } + return dateName; + }, + isLast(dateEvents, idx) { + return idx === dateEvents.length - 1; + }, + getBodyClasses(dateEvents, idx) { + return !this.isLast(dateEvents, idx) ? this.$options.borderClasses : ''; + }, + }, +}; +</script> + +<template> + <div> + <gl-loading-icon v-if="isLoading" size="md" /> + + <div v-else-if="hasEvents"> + <div + v-for="(dateEvents, key) in eventsList" + :key="key" + class="agent-activity-list issuable-discussion" + > + <h4 + class="gl-pb-4 gl-ml-5" + :class="$options.borderClasses" + data-testid="activity-section-title" + > + {{ key }} + </h4> + + <ul class="notes main-notes-list timeline"> + <activity-history-item + v-for="(event, idx) in dateEvents" + :key="idx" + :event="event" + :body-class="getBodyClasses(dateEvents, idx)" + /> + </ul> + </div> + </div> + + <gl-alert v-else-if="isError" variant="danger" :dismissible="false" class="gl-mt-3"> + {{ $options.i18n.error }} + </gl-alert> + + <gl-empty-state + v-else + :title="emptyStateTitle" + :svg-path="activityEmptyStateImage" + :svg-height="150" + > + <template #description + >{{ $options.i18n.emptyText }} + <gl-link + v-gl-tooltip + :href="$options.emptyHelpLink" + :title="$options.i18n.emptyTooltip" + :aria-label="$options.i18n.emptyTooltip" + ><gl-icon name="question" :size="14" + /></gl-link> + </template> + </gl-empty-state> + </div> +</template> diff --git a/app/assets/javascripts/clusters/agents/components/activity_history_item.vue b/app/assets/javascripts/clusters/agents/components/activity_history_item.vue new file mode 100644 index 00000000000..7792d89a575 --- /dev/null +++ b/app/assets/javascripts/clusters/agents/components/activity_history_item.vue @@ -0,0 +1,79 @@ +<script> +import { GlLink, GlIcon, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import HistoryItem from '~/vue_shared/components/registry/history_item.vue'; +import { EVENT_DETAILS, DEFAULT_ICON } from '../constants'; + +export default { + i18n: { + defaultBodyText: s__('ClusterAgents|Event occurred'), + }, + components: { + GlLink, + GlIcon, + GlSprintf, + TimeAgoTooltip, + HistoryItem, + }, + props: { + event: { + required: true, + type: Object, + }, + bodyClass: { + required: false, + default: '', + type: String, + }, + }, + computed: { + eventDetails() { + const defaultEvent = { + eventTypeIcon: DEFAULT_ICON, + title: this.event.kind, + body: this.$options.i18n.defaultBodyText, + }; + + const eventDetails = EVENT_DETAILS[this.event.kind] || defaultEvent; + const { eventTypeIcon, title, body, titleIcon } = eventDetails; + const resultEvent = { ...this.event, eventTypeIcon, title, body, titleIcon }; + + return resultEvent; + }, + }, +}; +</script> +<template> + <history-item :icon="eventDetails.eventTypeIcon" class="gl-my-0! gl-pr-0!"> + <strong> + <gl-sprintf :message="eventDetails.title" + ><template v-if="eventDetails.titleIcon" #titleIcon + ><gl-icon + class="gl-mr-2" + :name="eventDetails.titleIcon.name" + :size="12" + :class="eventDetails.titleIcon.class" + /> + </template> + <template #tokenName>{{ eventDetails.agentToken.name }}</template></gl-sprintf + > + </strong> + + <template #body> + <p class="gl-mt-2 gl-mb-0 gl-pb-2" :class="bodyClass"> + <gl-sprintf :message="eventDetails.body"> + <template #userName> + <span class="gl-font-weight-bold">{{ eventDetails.user.name }}</span> + <gl-link :href="eventDetails.user.webUrl">@{{ eventDetails.user.username }}</gl-link> + </template> + + <template #strong="{ content }"> + <span class="gl-font-weight-bold"> {{ content }} </span> + </template> + </gl-sprintf> + <time-ago-tooltip :time="eventDetails.recordedAt" /> + </p> + </template> + </history-item> +</template> diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue index afbba9d1f7c..9109c010500 100644 --- a/app/assets/javascripts/clusters/agents/components/show.vue +++ b/app/assets/javascripts/clusters/agents/components/show.vue @@ -8,11 +8,12 @@ import { GlTab, GlTabs, } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { MAX_LIST_COUNT } from '../constants'; import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql'; import TokenTable from './token_table.vue'; +import ActivityEvents from './activity_events_list.vue'; export default { i18n: { @@ -20,6 +21,7 @@ export default { loadingError: s__('ClusterAgents|An error occurred while loading your agent'), tokens: s__('ClusterAgents|Access tokens'), unknownUser: s__('ClusterAgents|Unknown user'), + activity: __('Activity'), }, apollo: { clusterAgent: { @@ -47,6 +49,7 @@ export default { GlTabs, TimeAgoTooltip, TokenTable, + ActivityEvents, }, props: { agentName: { @@ -127,9 +130,14 @@ export default { </gl-sprintf> </p> - <gl-tabs> + <gl-tabs sync-active-tab-with-query-params lazy> + <gl-tab :title="$options.i18n.activity" query-param-value="activity"> + <activity-events :agent-name="agentName" :project-path="projectPath" /> + </gl-tab> + <slot name="ee-security-tab"></slot> - <gl-tab> + + <gl-tab query-param-value="tokens"> <template #title> <span data-testid="cluster-agent-token-count"> {{ $options.i18n.tokens }} @@ -143,7 +151,7 @@ export default { <gl-loading-icon v-if="isLoading" size="md" class="gl-m-3" /> <div v-else> - <TokenTable :tokens="tokens" /> + <token-table :tokens="tokens" /> <div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5"> <gl-keyset-pagination v-bind="tokenPageInfo" @prev="prevPage" @next="nextPage" /> diff --git a/app/assets/javascripts/clusters/agents/components/token_table.vue b/app/assets/javascripts/clusters/agents/components/token_table.vue index 70ed2566134..019fac531d1 100644 --- a/app/assets/javascripts/clusters/agents/components/token_table.vue +++ b/app/assets/javascripts/clusters/agents/components/token_table.vue @@ -62,8 +62,8 @@ export default { ]; }, learnMoreUrl() { - return helpPagePath('user/clusters/agent/index.md', { - anchor: 'create-an-agent-record-in-gitlab', + return helpPagePath('user/clusters/agent/install/index', { + anchor: 'register-an-agent-with-gitlab', }); }, }, @@ -83,7 +83,14 @@ export default { </gl-link> </div> - <gl-table :items="tokens" :fields="fields" fixed stacked="md"> + <gl-table + :items="tokens" + :fields="fields" + fixed + stacked="md" + head-variant="white" + thead-class="gl-border-b-solid gl-border-b-2 gl-border-b-gray-100" + > <template #cell(lastUsed)="{ item }"> <time-ago-tooltip v-if="item.lastUsedAt" :time="item.lastUsedAt" /> <span v-else>{{ $options.i18n.neverUsed }}</span> diff --git a/app/assets/javascripts/clusters/agents/constants.js b/app/assets/javascripts/clusters/agents/constants.js index bbc4630f83b..315c7662755 100644 --- a/app/assets/javascripts/clusters/agents/constants.js +++ b/app/assets/javascripts/clusters/agents/constants.js @@ -1 +1,38 @@ +import { s__ } from '~/locale'; + export const MAX_LIST_COUNT = 25; + +export const EVENTS_STORED_DAYS = 7; + +export const EVENT_DETAILS = { + token_created: { + eventTypeIcon: 'token', + title: s__('ClusterAgents|%{tokenName} created'), + body: s__('ClusterAgents|Token created by %{userName}'), + }, + token_revoked: { + eventTypeIcon: 'token', + title: s__('ClusterAgents|%{tokenName} revoked'), + body: s__('ClusterAgents|Token revoked by %{userName}'), + }, + agent_connected: { + eventTypeIcon: 'connected', + title: s__('ClusterAgents|%{titleIcon}Connected'), + body: s__('ClusterAgents|Agent %{strongStart}connected%{strongEnd}'), + titleIcon: { + name: 'status-success', + class: 'text-success-500', + }, + }, + agent_disconnected: { + eventTypeIcon: 'connected', + title: s__('ClusterAgents|%{titleIcon}Not connected'), + body: s__('ClusterAgents|Agent %{strongStart}disconnected%{strongEnd}'), + titleIcon: { + name: 'severity-critical', + class: 'text-danger-800', + }, + }, +}; + +export const DEFAULT_ICON = 'token'; diff --git a/app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql b/app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql index 1e9187e8ad1..7deb057ede9 100644 --- a/app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql +++ b/app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql @@ -4,8 +4,8 @@ fragment Token on ClusterAgentToken { description lastUsedAt name - createdByUser { + id name } } diff --git a/app/assets/javascripts/clusters/agents/graphql/queries/get_agent_activity_events.query.graphql b/app/assets/javascripts/clusters/agents/graphql/queries/get_agent_activity_events.query.graphql new file mode 100644 index 00000000000..0d7ff029387 --- /dev/null +++ b/app/assets/javascripts/clusters/agents/graphql/queries/get_agent_activity_events.query.graphql @@ -0,0 +1,25 @@ +query getAgentActivityEvents($projectPath: ID!, $agentName: String!) { + project(fullPath: $projectPath) { + id + clusterAgent(name: $agentName) { + id + activityEvents { + nodes { + kind + level + recordedAt + agentToken { + id + name + } + user { + id + name + username + webUrl + } + } + } + } + } +} diff --git a/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql index d01db8f0a6a..3662e925261 100644 --- a/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql +++ b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql @@ -10,11 +10,13 @@ query getClusterAgent( $beforeToken: String ) { project(fullPath: $projectPath) { + id clusterAgent(name: $agentName) { id createdAt createdByUser { + id name } diff --git a/app/assets/javascripts/clusters/agents/index.js b/app/assets/javascripts/clusters/agents/index.js index 426d8d83847..5796c9e308d 100644 --- a/app/assets/javascripts/clusters/agents/index.js +++ b/app/assets/javascripts/clusters/agents/index.js @@ -13,11 +13,12 @@ export default () => { } const defaultClient = createDefaultClient(); - const { agentName, projectPath } = el.dataset; + const { agentName, projectPath, activityEmptyStateImage } = el.dataset; return new Vue({ el, apolloProvider: new VueApollo({ defaultClient }), + provide: { agentName, projectPath, activityEmptyStateImage }, render(createElement) { return createElement(AgentShowPage, { props: { diff --git a/app/assets/javascripts/clusters_list/components/agent_empty_state.vue b/app/assets/javascripts/clusters_list/components/agent_empty_state.vue index af44a23b4b3..f54f7b11414 100644 --- a/app/assets/javascripts/clusters_list/components/agent_empty_state.vue +++ b/app/assets/javascripts/clusters_list/components/agent_empty_state.vue @@ -1,107 +1,54 @@ <script> -import { GlButton, GlEmptyState, GlLink, GlSprintf, GlAlert, GlModalDirective } from '@gitlab/ui'; +import { GlButton, GlEmptyState, GlLink, GlSprintf, GlModalDirective } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { INSTALL_AGENT_MODAL_ID, I18N_AGENTS_EMPTY_STATE } from '../constants'; export default { i18n: I18N_AGENTS_EMPTY_STATE, modalId: INSTALL_AGENT_MODAL_ID, - multipleClustersDocsUrl: helpPagePath('user/project/clusters/multiple_kubernetes_clusters'), - installDocsUrl: helpPagePath('administration/clusters/kas'), - getStartedDocsUrl: helpPagePath('user/clusters/agent/index', { - anchor: 'define-a-configuration-repository', - }), + agentDocsUrl: helpPagePath('user/clusters/agent/index'), components: { GlButton, GlEmptyState, GlLink, GlSprintf, - GlAlert, }, directives: { GlModalDirective, }, - inject: ['emptyStateImage', 'projectPath'], + inject: ['emptyStateImage'], props: { - hasConfigurations: { - type: Boolean, - required: true, - }, isChildComponent: { default: false, required: false, type: Boolean, }, }, - computed: { - repositoryPath() { - return `/${this.projectPath}`; - }, - }, }; </script> <template> <gl-empty-state :svg-path="emptyStateImage" title="" class="agents-empty-state"> <template #description> - <p class="mw-460 gl-mx-auto gl-text-left"> - {{ $options.i18n.introText }} - </p> - <p class="mw-460 gl-mx-auto gl-text-left"> - <gl-sprintf :message="$options.i18n.multipleClustersText"> + <p class="gl-text-left"> + <gl-sprintf :message="$options.i18n.introText"> <template #link="{ content }"> - <gl-link - :href="$options.multipleClustersDocsUrl" - target="_blank" - data-testid="multiple-clusters-docs-link" - > + <gl-link :href="$options.agentDocsUrl"> {{ content }} </gl-link> </template> </gl-sprintf> </p> - - <p class="mw-460 gl-mx-auto"> - <gl-link :href="$options.installDocsUrl" target="_blank" data-testid="install-docs-link"> - {{ $options.i18n.learnMoreText }} - </gl-link> - </p> - - <gl-alert - v-if="!hasConfigurations" - variant="warning" - class="gl-mb-5 text-left" - :dismissible="false" - > - {{ $options.i18n.warningText }} - - <template #actions> - <gl-button - category="primary" - variant="info" - :href="$options.getStartedDocsUrl" - target="_blank" - class="gl-ml-0!" - > - {{ $options.i18n.readMoreText }} - </gl-button> - <gl-button category="secondary" variant="info" :href="repositoryPath"> - {{ $options.i18n.repositoryButtonText }} - </gl-button> - </template> - </gl-alert> </template> <template #actions> <gl-button v-if="!isChildComponent" v-gl-modal-directive="$options.modalId" - :disabled="!hasConfigurations" - data-testid="integration-primary-button" category="primary" variant="confirm" > - {{ $options.i18n.primaryButtonText }} + {{ $options.i18n.buttonText }} </gl-button> </template> </gl-empty-state> diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue index fb5cf7d1206..45108a28e37 100644 --- a/app/assets/javascripts/clusters_list/components/agents.vue +++ b/app/assets/javascripts/clusters_list/components/agents.vue @@ -86,9 +86,6 @@ export default { treePageInfo() { return this.agents?.project?.repository?.tree?.trees?.pageInfo || {}; }, - hasConfigurations() { - return Boolean(this.agents?.project?.repository?.tree?.trees?.nodes?.length); - }, }, methods: { reloadAgents() { @@ -161,11 +158,7 @@ export default { </div> </div> - <agent-empty-state - v-else - :has-configurations="hasConfigurations" - :is-child-component="isChildComponent" - /> + <agent-empty-state v-else :is-child-component="isChildComponent" /> </section> <gl-alert v-else variant="danger" :dismissible="false"> diff --git a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue index 9fb020d2f4f..1630d0d5c92 100644 --- a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue +++ b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue @@ -1,7 +1,6 @@ <script> import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { I18N_AVAILABLE_AGENTS_DROPDOWN } from '../constants'; -import agentConfigurations from '../graphql/queries/agent_configurations.query.graphql'; export default { name: 'AvailableAgentsDropdown', @@ -10,36 +9,22 @@ export default { GlDropdown, GlDropdownItem, }, - inject: ['projectPath'], props: { isRegistering: { required: true, type: Boolean, }, - }, - apollo: { - agents: { - query: agentConfigurations, - variables() { - return { - projectPath: this.projectPath, - }; - }, - update(data) { - this.populateAvailableAgents(data); - }, + availableAgents: { + required: true, + type: Array, }, }, data() { return { - availableAgents: [], selectedAgent: null, }; }, computed: { - isLoading() { - return this.$apollo.queries.agents.loading; - }, dropdownText() { if (this.isRegistering) { return this.$options.i18n.registeringAgent; @@ -58,18 +43,11 @@ export default { isSelected(agent) { return this.selectedAgent === agent; }, - populateAvailableAgents(data) { - const installedAgents = data?.project?.clusterAgents?.nodes.map((agent) => agent.name) ?? []; - const configuredAgents = - data?.project?.agentConfigurations?.nodes.map((config) => config.agentName) ?? []; - - this.availableAgents = configuredAgents.filter((agent) => !installedAgents.includes(agent)); - }, }, }; </script> <template> - <gl-dropdown :text="dropdownText" :loading="isLoading || isRegistering"> + <gl-dropdown :text="dropdownText" :loading="isRegistering"> <gl-dropdown-item v-for="agent in availableAgents" :key="agent" diff --git a/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue b/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue index 3879af6e9cb..ce601de57bd 100644 --- a/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue +++ b/app/assets/javascripts/clusters_list/components/clusters_empty_state.vue @@ -1,5 +1,5 @@ <script> -import { GlEmptyState, GlButton, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlEmptyState, GlButton, GlLink, GlSprintf, GlAlert } from '@gitlab/ui'; import { mapState } from 'vuex'; import { helpPagePath } from '~/helpers/help_page_helper'; import { I18N_CLUSTERS_EMPTY_STATE } from '../constants'; @@ -11,6 +11,7 @@ export default { GlButton, GlLink, GlSprintf, + GlAlert, }, inject: ['emptyStateHelpText', 'clustersEmptyStateImage', 'newClusterPath'], props: { @@ -20,8 +21,11 @@ export default { type: Boolean, }, }, - learnMoreHelpUrl: helpPagePath('user/project/clusters/index'), - multipleClustersHelpUrl: helpPagePath('user/project/clusters/multiple_kubernetes_clusters'), + clustersHelpUrl: helpPagePath('user/infrastructure/clusters/index', { + anchor: 'certificate-based-kubernetes-integration-deprecated', + }), + blogPostUrl: + 'https://about.gitlab.com/blog/2021/11/15/deprecating-the-cert-based-kubernetes-integration/', computed: { ...mapState(['canAddCluster']), }, @@ -29,48 +33,45 @@ export default { </script> <template> - <gl-empty-state :svg-path="clustersEmptyStateImage" title=""> - <template #description> - <p class="gl-text-left"> - {{ $options.i18n.description }} - </p> - <p class="gl-text-left"> - <gl-sprintf :message="$options.i18n.multipleClustersText"> - <template #link="{ content }"> - <gl-link - :href="$options.multipleClustersHelpUrl" - target="_blank" - data-testid="multiple-clusters-docs-link" - > - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </p> + <div> + <gl-empty-state :svg-path="clustersEmptyStateImage" title=""> + <template #description> + <p class="gl-text-left"> + <gl-sprintf :message="$options.i18n.introText"> + <template #link="{ content }"> + <gl-link :href="$options.clustersHelpUrl">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> - <p v-if="emptyStateHelpText" data-testid="clusters-empty-state-text"> - {{ emptyStateHelpText }} - </p> + <p v-if="emptyStateHelpText" data-testid="clusters-empty-state-text"> + {{ emptyStateHelpText }} + </p> + </template> - <p> - <gl-link :href="$options.learnMoreHelpUrl" target="_blank" data-testid="clusters-docs-link"> - {{ $options.i18n.learnMoreLinkText }} - </gl-link> - </p> - </template> + <template #actions> + <gl-button + v-if="!isChildComponent" + data-testid="integration-primary-button" + data-qa-selector="add_kubernetes_cluster_link" + category="primary" + variant="confirm" + :disabled="!canAddCluster" + :href="newClusterPath" + > + {{ $options.i18n.buttonText }} + </gl-button> + </template> + </gl-empty-state> - <template #actions> - <gl-button - v-if="!isChildComponent" - data-testid="integration-primary-button" - data-qa-selector="add_kubernetes_cluster_link" - category="primary" - variant="confirm" - :disabled="!canAddCluster" - :href="newClusterPath" - > - {{ $options.i18n.buttonText }} - </gl-button> - </template> - </gl-empty-state> + <gl-alert variant="warning" :dismissible="false"> + <gl-sprintf :message="$options.i18n.alertText"> + <template #link="{ content }"> + <gl-link :href="$options.blogPostUrl" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-alert> + </div> </template> diff --git a/app/assets/javascripts/clusters_list/components/clusters_main_view.vue b/app/assets/javascripts/clusters_list/components/clusters_main_view.vue index 9e03093aa67..7dd5ece9b8e 100644 --- a/app/assets/javascripts/clusters_list/components/clusters_main_view.vue +++ b/app/assets/javascripts/clusters_list/components/clusters_main_view.vue @@ -1,12 +1,22 @@ <script> import { GlTabs, GlTab } from '@gitlab/ui'; -import { CLUSTERS_TABS, MAX_CLUSTERS_LIST, MAX_LIST_COUNT, AGENT } from '../constants'; +import Tracking from '~/tracking'; +import { + CLUSTERS_TABS, + MAX_CLUSTERS_LIST, + MAX_LIST_COUNT, + AGENT, + EVENT_LABEL_TABS, + EVENT_ACTIONS_CHANGE, +} from '../constants'; import Agents from './agents.vue'; import InstallAgentModal from './install_agent_modal.vue'; import ClustersActions from './clusters_actions.vue'; import Clusters from './clusters.vue'; import ClustersViewAll from './clusters_view_all.vue'; +const trackingMixin = Tracking.mixin({ label: EVENT_LABEL_TABS }); + export default { components: { GlTabs, @@ -18,6 +28,7 @@ export default { InstallAgentModal, }, CLUSTERS_TABS, + mixins: [trackingMixin], props: { defaultBranchName: { default: '.noBranch', @@ -34,9 +45,12 @@ export default { methods: { onTabChange(tabName) { this.selectedTabIndex = CLUSTERS_TABS.findIndex((tab) => tab.queryParamValue === tabName); - this.maxAgents = tabName === AGENT ? MAX_LIST_COUNT : MAX_CLUSTERS_LIST; }, + trackTabChange(tab) { + const tabName = CLUSTERS_TABS[tab].queryParamValue; + this.track(EVENT_ACTIONS_CHANGE, { property: tabName }); + }, }, }; </script> @@ -47,6 +61,7 @@ export default { sync-active-tab-with-query-params nav-class="gl-flex-grow-1 gl-align-items-center" lazy + @input="trackTabChange" > <gl-tab v-for="(tab, idx) in $options.CLUSTERS_TABS" diff --git a/app/assets/javascripts/clusters_list/components/clusters_view_all.vue b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue index 285876e57d8..0e312d21e4e 100644 --- a/app/assets/javascripts/clusters_list/components/clusters_view_all.vue +++ b/app/assets/javascripts/clusters_list/components/clusters_view_all.vue @@ -34,10 +34,12 @@ export default { directives: { GlModalDirective, }, - AGENT_CARD_INFO, - CERTIFICATE_BASED_CARD_INFO, MAX_CLUSTERS_LIST, INSTALL_AGENT_MODAL_ID, + i18n: { + agent: AGENT_CARD_INFO, + certificate: CERTIFICATE_BASED_CARD_INFO, + }, inject: ['addClusterPath'], props: { defaultBranchName: { @@ -122,21 +124,21 @@ export default { </gl-sprintf> </h3> - <gl-badge id="clusters-recommended-badge" size="md" variant="info">{{ - $options.AGENT_CARD_INFO.tooltip.label + <gl-badge id="clusters-recommended-badge" variant="info">{{ + $options.i18n.agent.tooltip.label }}</gl-badge> <gl-popover target="clusters-recommended-badge" container="viewport" placement="bottom" - :title="$options.AGENT_CARD_INFO.tooltip.title" + :title="$options.i18n.agent.tooltip.title" > <p class="gl-mb-0"> - <gl-sprintf :message="$options.AGENT_CARD_INFO.tooltip.text"> + <gl-sprintf :message="$options.i18n.agent.tooltip.text"> <template #link="{ content }"> <gl-link - :href="$options.AGENT_CARD_INFO.tooltip.link" + :href="$options.i18n.agent.tooltip.link" target="_blank" class="gl-font-sm" > @@ -159,9 +161,9 @@ export default { <gl-link v-if="totalAgents" data-testid="agents-tab-footer-link" - :href="`?tab=${$options.AGENT_CARD_INFO.tabName}`" - @click="changeTab($event, $options.AGENT_CARD_INFO.tabName)" - ><gl-sprintf :message="$options.AGENT_CARD_INFO.footerText" + :href="`?tab=${$options.i18n.agent.tabName}`" + @click="changeTab($event, $options.i18n.agent.tabName)" + ><gl-sprintf :message="$options.i18n.agent.footerText" ><template #number>{{ cardFooterNumber(totalAgents) }}</template></gl-sprintf ></gl-link ><gl-button @@ -169,7 +171,7 @@ export default { class="gl-ml-4" category="secondary" variant="confirm" - >{{ $options.AGENT_CARD_INFO.actionText }}</gl-button + >{{ $options.i18n.agent.actionText }}</gl-button > </template> </gl-card> @@ -190,6 +192,7 @@ export default { <template #total>{{ clustersCardTitle.total }}</template> </gl-sprintf> </h3> + <gl-badge variant="warning">{{ $options.i18n.certificate.badgeText }}</gl-badge> </template> <clusters :limit="$options.MAX_CLUSTERS_LIST" :is-child-component="true" /> @@ -198,9 +201,9 @@ export default { <gl-link v-if="totalClusters" data-testid="clusters-tab-footer-link" - :href="`?tab=${$options.CERTIFICATE_BASED_CARD_INFO.tabName}`" - @click="changeTab($event, $options.CERTIFICATE_BASED_CARD_INFO.tabName)" - ><gl-sprintf :message="$options.CERTIFICATE_BASED_CARD_INFO.footerText" + :href="`?tab=${$options.i18n.certificate.tabName}`" + @click="changeTab($event, $options.i18n.certificate.tabName)" + ><gl-sprintf :message="$options.i18n.certificate.footerText" ><template #number>{{ cardFooterNumber(totalClusters) }}</template></gl-sprintf ></gl-link ><gl-button @@ -209,7 +212,7 @@ export default { variant="confirm" class="gl-ml-4" :href="addClusterPath" - >{{ $options.CERTIFICATE_BASED_CARD_INFO.actionText }}</gl-button + >{{ $options.i18n.certificate.actionText }}</gl-button > </template> </gl-card> diff --git a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue index 6eb2e85ecea..5eef76252bd 100644 --- a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue +++ b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue @@ -9,22 +9,48 @@ import { GlSprintf, } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; import CodeBlock from '~/vue_shared/components/code_block.vue'; +import Tracking from '~/tracking'; import { generateAgentRegistrationCommand } from '../clusters_util'; -import { INSTALL_AGENT_MODAL_ID, I18N_INSTALL_AGENT_MODAL } from '../constants'; -import { addAgentToStore } from '../graphql/cache_update'; +import { + INSTALL_AGENT_MODAL_ID, + I18N_AGENT_MODAL, + KAS_DISABLED_ERROR, + EVENT_LABEL_MODAL, + EVENT_ACTIONS_OPEN, + EVENT_ACTIONS_SELECT, + EVENT_ACTIONS_CLICK, + MODAL_TYPE_EMPTY, + MODAL_TYPE_REGISTER, +} from '../constants'; +import { addAgentToStore, addAgentConfigToStore } from '../graphql/cache_update'; import createAgent from '../graphql/mutations/create_agent.mutation.graphql'; import createAgentToken from '../graphql/mutations/create_agent_token.mutation.graphql'; import getAgentsQuery from '../graphql/queries/get_agents.query.graphql'; +import agentConfigurations from '../graphql/queries/agent_configurations.query.graphql'; import AvailableAgentsDropdown from './available_agents_dropdown.vue'; +const trackingMixin = Tracking.mixin({ label: EVENT_LABEL_MODAL }); + export default { modalId: INSTALL_AGENT_MODAL_ID, - i18n: I18N_INSTALL_AGENT_MODAL, + EVENT_ACTIONS_OPEN, + EVENT_ACTIONS_CLICK, + EVENT_LABEL_MODAL, + basicInstallPath: helpPagePath('user/clusters/agent/install/index', { + anchor: 'install-the-agent-into-the-cluster', + }), + advancedInstallPath: helpPagePath('user/clusters/agent/install/index', { + anchor: 'advanced-installation', + }), + enableKasPath: helpPagePath('administration/clusters/kas'), + installAgentPath: helpPagePath('user/clusters/agent/install/index'), + registerAgentPath: helpPagePath('user/clusters/agent/install/index', { + anchor: 'register-an-agent-with-gitlab', + }), components: { AvailableAgentsDropdown, - ClipboardButton, CodeBlock, GlAlert, GlButton, @@ -33,8 +59,10 @@ export default { GlLink, GlModal, GlSprintf, + ModalCopyButton, }, - inject: ['projectPath', 'kasAddress'], + mixins: [trackingMixin], + inject: ['projectPath', 'kasAddress', 'emptyStateImage'], props: { defaultBranchName: { default: '.noBranch', @@ -46,6 +74,22 @@ export default { type: Number, }, }, + apollo: { + agents: { + query: agentConfigurations, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update(data) { + this.populateAvailableAgents(data); + }, + error(error) { + this.kasDisabled = error?.message?.indexOf(KAS_DISABLED_ERROR) >= 0; + }, + }, + }, data() { return { registering: false, @@ -53,6 +97,8 @@ export default { agentToken: null, error: null, clusterAgent: null, + availableAgents: [], + kasDisabled: false, }; }, computed: { @@ -63,19 +109,11 @@ export default { return !this.registering && this.agentName !== null; }, canCancel() { - return !this.registered && !this.registering; + return !this.registered && !this.registering && this.isAgentRegistrationModal; }, agentRegistrationCommand() { return generateAgentRegistrationCommand(this.agentToken, this.kasAddress); }, - basicInstallPath() { - return helpPagePath('user/clusters/agent/install/index', { - anchor: 'install-the-agent-into-the-cluster', - }); - }, - advancedInstallPath() { - return helpPagePath('user/clusters/agent/install/index', { anchor: 'advanced-installation' }); - }, getAgentsQueryVariables() { return { defaultBranchName: this.defaultBranchName, @@ -84,10 +122,31 @@ export default { projectPath: this.projectPath, }; }, + i18n() { + return I18N_AGENT_MODAL[this.modalType]; + }, + repositoryPath() { + return `/${this.projectPath}`; + }, + modalType() { + return !this.availableAgents?.length && !this.registered + ? MODAL_TYPE_EMPTY + : MODAL_TYPE_REGISTER; + }, + modalSize() { + return this.isEmptyStateModal ? 'sm' : 'md'; + }, + isEmptyStateModal() { + return this.modalType === MODAL_TYPE_EMPTY; + }, + isAgentRegistrationModal() { + return this.modalType === MODAL_TYPE_REGISTER; + }, }, methods: { setAgentName(name) { this.agentName = name; + this.track(EVENT_ACTIONS_SELECT); }, closeModal() { this.$refs.modal.hide(); @@ -96,8 +155,16 @@ export default { this.registering = false; this.agentName = null; this.agentToken = null; + this.clusterAgent = null; this.error = null; }, + populateAvailableAgents(data) { + const installedAgents = data?.project?.clusterAgents?.nodes.map((agent) => agent.name) ?? []; + const configuredAgents = + data?.project?.agentConfigurations?.nodes.map((config) => config.agentName) ?? []; + + this.availableAgents = configuredAgents.filter((agent) => !installedAgents.includes(agent)); + }, createAgentMutation() { return this.$apollo .mutate({ @@ -117,7 +184,9 @@ export default { ); }, }) - .then(({ data: { createClusterAgent } }) => createClusterAgent); + .then(({ data: { createClusterAgent } }) => { + return createClusterAgent; + }); }, createAgentTokenMutation(agendId) { return this.$apollo @@ -129,6 +198,17 @@ export default { name: this.agentName, }, }, + update: (store, { data: { clusterAgentTokenCreate } }) => { + addAgentConfigToStore( + store, + clusterAgentTokenCreate, + this.clusterAgent, + agentConfigurations, + { + projectPath: this.projectPath, + }, + ); + }, }) .then(({ data: { clusterAgentTokenCreate } }) => clusterAgentTokenCreate); }, @@ -158,7 +238,7 @@ export default { if (error) { this.error = error.message; } else { - this.error = this.$options.i18n.unknownError; + this.error = this.i18n.unknownError; } } finally { this.registering = false; @@ -172,115 +252,172 @@ export default { <gl-modal ref="modal" :modal-id="$options.modalId" - :title="$options.i18n.modalTitle" + :title="i18n.modalTitle" + :size="modalSize" static lazy @hidden="resetModal" + @show="track($options.EVENT_ACTIONS_OPEN, { property: modalType })" > - <template v-if="!registered"> - <p> - <strong>{{ $options.i18n.selectAgentTitle }}</strong> - </p> + <template v-if="isAgentRegistrationModal"> + <template v-if="!registered"> + <p> + <strong>{{ i18n.selectAgentTitle }}</strong> + </p> - <p> - <gl-sprintf :message="$options.i18n.selectAgentBody"> - <template #link="{ content }"> - <gl-link :href="basicInstallPath" target="_blank"> {{ content }}</gl-link> - </template> - </gl-sprintf> - </p> + <p class="gl-mb-0">{{ i18n.selectAgentBody }}</p> + <p> + <gl-link :href="$options.registerAgentPath"> {{ i18n.learnMoreLink }}</gl-link> + </p> - <form> - <gl-form-group label-for="agent-name"> - <available-agents-dropdown - class="gl-w-70p" - :is-registering="registering" - @agentSelected="setAgentName" - /> - </gl-form-group> - </form> + <form> + <gl-form-group label-for="agent-name"> + <available-agents-dropdown + class="gl-w-70p" + :is-registering="registering" + :available-agents="availableAgents" + @agentSelected="setAgentName" + /> + </gl-form-group> + </form> - <p v-if="error"> - <gl-alert - :title="$options.i18n.registrationErrorTitle" - variant="danger" - :dismissible="false" - > - {{ error }} - </gl-alert> - </p> - </template> + <p v-if="error"> + <gl-alert :title="i18n.registrationErrorTitle" variant="danger" :dismissible="false"> + {{ error }} + </gl-alert> + </p> + </template> - <template v-else> - <p> - <strong>{{ $options.i18n.tokenTitle }}</strong> - </p> + <template v-else> + <p> + <strong>{{ i18n.tokenTitle }}</strong> + </p> - <p> - <gl-sprintf :message="$options.i18n.tokenBody"> - <template #link="{ content }"> - <gl-link :href="basicInstallPath" target="_blank"> {{ content }}</gl-link> - </template> - </gl-sprintf> - </p> + <p> + <gl-sprintf :message="i18n.tokenBody"> + <template #link="{ content }"> + <gl-link :href="$options.basicInstallPath" target="_blank"> {{ content }}</gl-link> + </template> + </gl-sprintf> + </p> - <p> - <gl-alert - :title="$options.i18n.tokenSingleUseWarningTitle" - variant="warning" - :dismissible="false" - > - {{ $options.i18n.tokenSingleUseWarningBody }} - </gl-alert> - </p> + <p> + <gl-alert :title="i18n.tokenSingleUseWarningTitle" variant="warning" :dismissible="false"> + {{ i18n.tokenSingleUseWarningBody }} + </gl-alert> + </p> - <p> - <gl-form-input-group readonly :value="agentToken" :select-on-click="true"> - <template #append> - <clipboard-button :text="agentToken" :title="$options.i18n.copyToken" /> - </template> - </gl-form-input-group> - </p> + <p> + <gl-form-input-group readonly :value="agentToken" :select-on-click="true"> + <template #append> + <modal-copy-button + :text="agentToken" + :title="i18n.copyToken" + :modal-id="$options.modalId" + /> + </template> + </gl-form-input-group> + </p> - <p> - <strong>{{ $options.i18n.basicInstallTitle }}</strong> - </p> + <p> + <strong>{{ i18n.basicInstallTitle }}</strong> + </p> - <p> - {{ $options.i18n.basicInstallBody }} - </p> + <p> + {{ i18n.basicInstallBody }} + </p> - <p> - <code-block :code="agentRegistrationCommand" /> - </p> + <p> + <code-block :code="agentRegistrationCommand" /> + </p> + + <p> + <strong>{{ i18n.advancedInstallTitle }}</strong> + </p> + + <p> + <gl-sprintf :message="i18n.advancedInstallBody"> + <template #link="{ content }"> + <gl-link :href="$options.advancedInstallPath" target="_blank"> {{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </template> + </template> + + <template v-else> + <div class="gl-text-center gl-mb-5"> + <img :alt="i18n.altText" :src="emptyStateImage" height="100" /> + </div> <p> - <strong>{{ $options.i18n.advancedInstallTitle }}</strong> + <gl-sprintf :message="i18n.modalBody"> + <template #link="{ content }"> + <gl-link :href="$options.installAgentPath"> {{ content }}</gl-link> + </template> + </gl-sprintf> </p> - <p> - <gl-sprintf :message="$options.i18n.advancedInstallBody"> + <p v-if="kasDisabled"> + <gl-sprintf :message="i18n.enableKasText"> <template #link="{ content }"> - <gl-link :href="advancedInstallPath" target="_blank"> {{ content }}</gl-link> + <gl-link :href="$options.enableKasPath"> {{ content }}</gl-link> </template> </gl-sprintf> </p> </template> <template #modal-footer> - <gl-button v-if="canCancel" @click="closeModal">{{ $options.i18n.cancel }} </gl-button> - - <gl-button v-if="registered" variant="confirm" category="primary" @click="closeModal" - >{{ $options.i18n.close }} + <gl-button + v-if="registered" + variant="confirm" + category="primary" + :data-track-action="$options.EVENT_ACTIONS_CLICK" + :data-track-label="$options.EVENT_LABEL_MODAL" + data-track-property="close" + @click="closeModal" + >{{ i18n.close }} </gl-button> <gl-button - v-else + v-else-if="isAgentRegistrationModal" :disabled="!nextButtonDisabled" variant="confirm" category="primary" + :data-track-action="$options.EVENT_ACTIONS_CLICK" + :data-track-label="$options.EVENT_LABEL_MODAL" + data-track-property="register" @click="registerAgent" - >{{ $options.i18n.registerAgentButton }} + >{{ i18n.registerAgentButton }} + </gl-button> + + <gl-button + v-if="canCancel" + :data-track-action="$options.EVENT_ACTIONS_CLICK" + :data-track-label="$options.EVENT_LABEL_MODAL" + data-track-property="cancel" + @click="closeModal" + >{{ i18n.cancel }} + </gl-button> + + <gl-button + v-if="isEmptyStateModal" + :href="repositoryPath" + variant="confirm" + category="secondary" + data-testid="agent-secondary-button" + >{{ i18n.secondaryButton }} + </gl-button> + + <gl-button + v-if="isEmptyStateModal" + variant="confirm" + category="primary" + :data-track-action="$options.EVENT_ACTIONS_CLICK" + :data-track-label="$options.EVENT_LABEL_MODAL" + data-track-property="done" + @click="closeModal" + >{{ i18n.done }} </gl-button> </template> </gl-modal> diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js index 9fefdf450c4..9b52df74fc5 100644 --- a/app/assets/javascripts/clusters_list/constants.js +++ b/app/assets/javascripts/clusters_list/constants.js @@ -64,47 +64,63 @@ export const STATUSES = { creating: { title: __('Creating') }, }; -export const I18N_INSTALL_AGENT_MODAL = { - registerAgentButton: s__('ClusterAgents|Register Agent'), - close: __('Close'), - cancel: __('Cancel'), - - modalTitle: s__('ClusterAgents|Install new Agent'), - - selectAgentTitle: s__('ClusterAgents|Select which Agent you want to install'), - selectAgentBody: s__( - `ClusterAgents|Select the Agent you want to register with GitLab and install on your cluster. To learn more about the Kubernetes Agent registration process %{linkStart}go to the documentation%{linkEnd}.`, - ), +export const I18N_AGENT_MODAL = { + agent_registration: { + registerAgentButton: s__('ClusterAgents|Register'), + close: __('Close'), + cancel: __('Cancel'), + + modalTitle: s__('ClusterAgents|Connect a cluster through the Agent'), + selectAgentTitle: s__('ClusterAgents|Select an agent to register with GitLab'), + selectAgentBody: s__( + 'ClusterAgents|Register an agent to generate a token that will be used to install the agent on your cluster in the next step.', + ), + learnMoreLink: s__('ClusterAgents|How to register an agent?'), - copyToken: s__('ClusterAgents|Copy token'), - tokenTitle: s__('ClusterAgents|Registration token'), - tokenBody: s__( - `ClusterAgents|The registration token will be used to connect the Agent on your cluster to GitLab. To learn more about the registration tokens and how they are used %{linkStart}go to the documentation%{linkEnd}.`, - ), + copyToken: s__('ClusterAgents|Copy token'), + tokenTitle: s__('ClusterAgents|Registration token'), + tokenBody: s__( + `ClusterAgents|The registration token will be used to connect the agent on your cluster to GitLab. %{linkStart}What are registration tokens?%{linkEnd}`, + ), - tokenSingleUseWarningTitle: s__( - 'ClusterAgents|The token value will not be shown again after you close this window.', - ), - tokenSingleUseWarningBody: s__( - `ClusterAgents|The recommended installation method provided below includes the token. If you want to follow the alternative installation method provided in the docs make sure you save the token value before you close the window.`, - ), + tokenSingleUseWarningTitle: s__( + 'ClusterAgents|You cannot see this token again after you close this window.', + ), + tokenSingleUseWarningBody: s__( + `ClusterAgents|The recommended installation method includes the token. If you want to follow the advanced installation method provided in the docs, make sure you save the token value before you close this window.`, + ), - basicInstallTitle: s__('ClusterAgents|Recommended installation method'), - basicInstallBody: __( - `Open a CLI and connect to the cluster you want to install the Agent in. Use this installation method to minimize any manual steps. The token is already included in the command.`, - ), + basicInstallTitle: s__('ClusterAgents|Recommended installation method'), + basicInstallBody: __( + `Open a CLI and connect to the cluster you want to install the agent in. Use this installation method to minimize any manual steps. The token is already included in the command.`, + ), - advancedInstallTitle: s__('ClusterAgents|Alternative installation methods'), - advancedInstallBody: s__( - 'ClusterAgents|For alternative installation methods %{linkStart}go to the documentation%{linkEnd}.', - ), + advancedInstallTitle: s__('ClusterAgents|Advanced installation methods'), + advancedInstallBody: s__( + 'ClusterAgents|For the advanced installation method %{linkStart}see the documentation%{linkEnd}.', + ), - registrationErrorTitle: __('Failed to register Agent'), - unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'), + registrationErrorTitle: s__('ClusterAgents|Failed to register an agent'), + unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'), + }, + empty_state: { + modalTitle: s__('ClusterAgents|Connect your cluster through the Agent'), + modalBody: s__( + "ClusterAgents|To install a new agent, first add the agent's configuration file to this repository. %{linkStart}What's the agent's configuration file?%{linkEnd}", + ), + enableKasText: s__( + "ClusterAgents|Your instance doesn't have the %{linkStart}GitLab Agent Server (KAS)%{linkEnd} set up. Ask a GitLab Administrator to install it.", + ), + altText: s__('ClusterAgents|GitLab Agent for Kubernetes'), + secondaryButton: s__('ClusterAgents|Go to the repository files'), + done: __('Cancel'), + }, }; +export const KAS_DISABLED_ERROR = 'Gitlab::Kas::Client::ConfigurationError'; + export const I18N_AVAILABLE_AGENTS_DROPDOWN = { - selectAgent: s__('ClusterAgents|Select an Agent'), + selectAgent: s__('ClusterAgents|Select an agent'), registeringAgent: s__('ClusterAgents|Registering Agent'), }; @@ -125,7 +141,7 @@ export const AGENT_STATUSES = { title: s__('ClusterAgents|Agent might not be connected to GitLab'), body: sprintf( s__( - 'ClusterAgents|The Agent has not been connected in a long time. There might be a connectivity issue. Last contact was %{timeAgo}.', + 'ClusterAgents|The agent has not been connected in a long time. There might be a connectivity issue. Last contact was %{timeAgo}.', ), ), }, @@ -143,55 +159,48 @@ export const AGENT_STATUSES = { export const I18N_AGENTS_EMPTY_STATE = { introText: s__( - 'ClusterAgents|Use GitLab Agents to more securely integrate with your clusters to deploy your applications, run your pipelines, use review apps and much more.', - ), - multipleClustersText: s__( - 'ClusterAgents|If you are setting up multiple clusters and are using Auto DevOps, %{linkStart}read about using multiple Kubernetes clusters first.%{linkEnd}', - ), - learnMoreText: s__('ClusterAgents|Learn more about the GitLab Kubernetes Agent.'), - warningText: s__( - 'ClusterAgents|To install an Agent you should create an agent directory in the Repository first. We recommend that you add the Agent configuration to the directory before you start the installation process.', + 'ClusterIntegration|Use the %{linkStart}GitLab Agent%{linkEnd} to safely connect your Kubernetes clusters to GitLab. You can deploy your applications, run your pipelines, use Review Apps, and much more.', ), - readMoreText: s__('ClusterAgents|Read more about getting started'), - repositoryButtonText: s__('ClusterAgents|Go to the repository'), - primaryButtonText: s__('ClusterAgents|Connect with a GitLab Agent'), + buttonText: s__('ClusterAgents|Connect with the GitLab Agent'), }; export const I18N_CLUSTERS_EMPTY_STATE = { - description: s__( - 'ClusterIntegration|Use certificates to integrate with your clusters to deploy your applications, run your pipelines, use review apps and much more in an easy way.', - ), - multipleClustersText: s__( - 'ClusterIntegration|If you are setting up multiple clusters and are using Auto DevOps, %{linkStart}read about using multiple Kubernetes clusters first.%{linkEnd}', + introText: s__( + 'ClusterIntegration|Connect your cluster to GitLab through %{linkStart}cluster certificates%{linkEnd}.', ), - learnMoreLinkText: s__('ClusterIntegration|Learn more about the GitLab managed clusters'), buttonText: s__('ClusterIntegration|Connect with a certificate'), + alertText: s__( + 'ClusterIntegration|The certificate-based method to connect clusters to GitLab was %{linkStart}deprecated%{linkEnd} in GitLab 14.5.', + ), }; export const AGENT_CARD_INFO = { tabName: 'agent', - title: sprintf(s__('ClusterAgents|%{number} of %{total} Agent based integrations')), - emptyTitle: s__('ClusterAgents|No Agent based integrations'), + title: sprintf(s__('ClusterAgents|%{number} of %{total} agents')), + emptyTitle: s__('ClusterAgents|No agents'), tooltip: { label: s__('ClusterAgents|Recommended'), - title: s__('ClusterAgents|GitLab Agents'), + title: s__('ClusterAgents|GitLab Agent'), text: sprintf( s__( - 'ClusterAgents|GitLab Agents provide an increased level of security when integrating with clusters. %{linkStart}Learn more about the GitLab Kubernetes Agent.%{linkEnd}', + 'ClusterAgents|The GitLab Agent provides an increased level of security when connecting Kubernetes clusters to GitLab. %{linkStart}Learn more about the GitLab Agent.%{linkEnd}', ), ), link: helpPagePath('user/clusters/agent/index'), }, - actionText: s__('ClusterAgents|Install new Agent'), - footerText: sprintf(s__('ClusterAgents|View all %{number} Agent based integrations')), + actionText: s__('ClusterAgents|Install a new agent'), + footerText: sprintf(s__('ClusterAgents|View all %{number} agents')), }; export const CERTIFICATE_BASED_CARD_INFO = { tabName: 'certificate_based', - title: sprintf(s__('ClusterAgents|%{number} of %{total} Certificate based integrations')), - emptyTitle: s__('ClusterAgents|No Certificate based integrations'), + title: sprintf( + s__('ClusterAgents|%{number} of %{total} clusters connected through cluster certificates'), + ), + emptyTitle: s__('ClusterAgents|No clusters connected through cluster certificates'), actionText: s__('ClusterAgents|Connect existing cluster'), - footerText: sprintf(s__('ClusterAgents|View all %{number} Certificate based integrations')), + footerText: sprintf(s__('ClusterAgents|View all %{number} clusters')), + badgeText: s__('ClusterAgents|Deprecated'), }; export const MAX_CLUSTERS_LIST = 6; @@ -208,7 +217,7 @@ export const CLUSTERS_TABS = [ queryParamValue: 'agent', }, { - title: s__('ClusterAgents|Certificate based'), + title: s__('ClusterAgents|Certificate'), component: 'clusters', queryParamValue: 'certificate_based', }, @@ -216,10 +225,20 @@ export const CLUSTERS_TABS = [ export const CLUSTERS_ACTIONS = { actionsButton: s__('ClusterAgents|Actions'), - createNewCluster: s__('ClusterAgents|Create new cluster'), - connectWithAgent: s__('ClusterAgents|Connect with Agent'), - connectExistingCluster: s__('ClusterAgents|Connect with certificate'), + createNewCluster: s__('ClusterAgents|Create a new cluster'), + connectWithAgent: s__('ClusterAgents|Connect with the Agent'), + connectExistingCluster: s__('ClusterAgents|Connect with a certificate'), }; export const AGENT = 'agent'; export const CERTIFICATE_BASED = 'certificate_based'; + +export const EVENT_LABEL_MODAL = 'agent_registration_modal'; +export const EVENT_LABEL_TABS = 'kubernetes_section_tabs'; +export const EVENT_ACTIONS_OPEN = 'open_modal'; +export const EVENT_ACTIONS_SELECT = 'select_agent'; +export const EVENT_ACTIONS_CLICK = 'click_button'; +export const EVENT_ACTIONS_CHANGE = 'change_tab'; + +export const MODAL_TYPE_EMPTY = 'empty_state'; +export const MODAL_TYPE_REGISTER = 'agent_registration'; diff --git a/app/assets/javascripts/clusters_list/graphql/cache_update.js b/app/assets/javascripts/clusters_list/graphql/cache_update.js index dd633820952..4d12bc8151c 100644 --- a/app/assets/javascripts/clusters_list/graphql/cache_update.js +++ b/app/assets/javascripts/clusters_list/graphql/cache_update.js @@ -1,29 +1,65 @@ import produce from 'immer'; import { getAgentConfigPath } from '../clusters_util'; +export const hasErrors = ({ errors = [] }) => errors?.length; + export function addAgentToStore(store, createClusterAgent, query, variables) { - const { clusterAgent } = createClusterAgent; - const sourceData = store.readQuery({ - query, - variables, - }); - - const data = produce(sourceData, (draftData) => { - const configuration = { - name: clusterAgent.name, - path: getAgentConfigPath(clusterAgent.name), - webPath: clusterAgent.webPath, - __typename: 'TreeEntry', - }; - - draftData.project.clusterAgents.nodes.push(clusterAgent); - draftData.project.clusterAgents.count += 1; - draftData.project.repository.tree.trees.nodes.push(configuration); - }); - - store.writeQuery({ - query, - variables, - data, - }); + if (!hasErrors(createClusterAgent)) { + const { clusterAgent } = createClusterAgent; + const sourceData = store.readQuery({ + query, + variables, + }); + + const data = produce(sourceData, (draftData) => { + const configuration = { + id: clusterAgent.id, + name: clusterAgent.name, + path: getAgentConfigPath(clusterAgent.name), + webPath: clusterAgent.webPath, + __typename: 'TreeEntry', + }; + + draftData.project.clusterAgents.nodes.push(clusterAgent); + draftData.project.clusterAgents.count += 1; + draftData.project.repository.tree.trees.nodes.push(configuration); + }); + + store.writeQuery({ + query, + variables, + data, + }); + } +} + +export function addAgentConfigToStore( + store, + clusterAgentTokenCreate, + clusterAgent, + query, + variables, +) { + if (!hasErrors(clusterAgentTokenCreate)) { + const sourceData = store.readQuery({ + query, + variables, + }); + + const data = produce(sourceData, (draftData) => { + const configuration = { + agentName: clusterAgent.name, + __typename: 'AgentConfiguration', + }; + + draftData.project.clusterAgents.nodes.push(clusterAgent); + draftData.project.agentConfigurations.nodes.push(configuration); + }); + + store.writeQuery({ + query, + variables, + data, + }); + } } diff --git a/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql b/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql index 9b40260471c..cd46dfee170 100644 --- a/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql +++ b/app/assets/javascripts/clusters_list/graphql/fragments/cluster_agent.fragment.graphql @@ -4,6 +4,7 @@ fragment ClusterAgentFragment on ClusterAgent { webPath tokens { nodes { + id lastUsedAt } } diff --git a/app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql b/app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql index 40b61337024..9a24cec5a9c 100644 --- a/app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql +++ b/app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql @@ -1,5 +1,6 @@ query agentConfigurations($projectPath: ID!) { project(fullPath: $projectPath) { + id agentConfigurations { nodes { agentName @@ -8,6 +9,7 @@ query agentConfigurations($projectPath: ID!) { clusterAgents { nodes { + id name } } diff --git a/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql index 47b25988877..f8efb6683f6 100644 --- a/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql +++ b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql @@ -12,6 +12,7 @@ query getAgents( $beforeTree: String ) { project(fullPath: $projectPath) { + id clusterAgents(first: $first, last: $last, before: $beforeAgent, after: $afterAgent) { nodes { ...ClusterAgentFragment @@ -28,6 +29,7 @@ query getAgents( tree(path: ".gitlab/agents", ref: $defaultBranchName) { trees(first: $first, last: $last, after: $afterTree, before: $beforeTree) { nodes { + id name path webPath diff --git a/app/assets/javascripts/code_navigation/components/doc_line.vue b/app/assets/javascripts/code_navigation/components/doc_line.vue index 69d398893d9..4d44c984833 100644 --- a/app/assets/javascripts/code_navigation/components/doc_line.vue +++ b/app/assets/javascripts/code_navigation/components/doc_line.vue @@ -18,5 +18,6 @@ export default { <span v-for="(token, tokenIndex) in tokens" :key="tokenIndex" :class="token.class">{{ token.value }}</span> + <br /> </span> </template> diff --git a/app/assets/javascripts/content_editor/extensions/attachment.js b/app/assets/javascripts/content_editor/extensions/attachment.js index 29ee282f2d2..72df1d071d1 100644 --- a/app/assets/javascripts/content_editor/extensions/attachment.js +++ b/app/assets/javascripts/content_editor/extensions/attachment.js @@ -5,9 +5,11 @@ import { handleFileEvent } from '../services/upload_helpers'; export default Extension.create({ name: 'attachment', - defaultOptions: { - uploadsPath: null, - renderMarkdown: null, + addOptions() { + return { + uploadsPath: null, + renderMarkdown: null, + }; }, addCommands() { diff --git a/app/assets/javascripts/content_editor/extensions/audio.js b/app/assets/javascripts/content_editor/extensions/audio.js index 25d4068c93f..ea48ee0cee0 100644 --- a/app/assets/javascripts/content_editor/extensions/audio.js +++ b/app/assets/javascripts/content_editor/extensions/audio.js @@ -2,8 +2,10 @@ import Playable from './playable'; export default Playable.extend({ name: 'audio', - defaultOptions: { - ...Playable.options, - mediaType: 'audio', + addOptions() { + return { + ...this.parent?.(), + mediaType: 'audio', + }; }, }); diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js index 1ed1ab0315f..ea51bee3ba9 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -4,6 +4,8 @@ import * as lowlight from 'lowlight'; const extractLanguage = (element) => element.getAttribute('lang'); export default CodeBlockLowlight.extend({ + isolating: true, + addAttributes() { return { language: { @@ -17,7 +19,7 @@ export default CodeBlockLowlight.extend({ }; }, renderHTML({ HTMLAttributes }) { - return ['pre', HTMLAttributes, ['code', {}, 0]]; + return ['div', ['pre', HTMLAttributes, ['code', {}, 0]]]; }, }).configure({ lowlight, diff --git a/app/assets/javascripts/content_editor/extensions/division.js b/app/assets/javascripts/content_editor/extensions/division.js index c70d1700941..566ed85acf3 100644 --- a/app/assets/javascripts/content_editor/extensions/division.js +++ b/app/assets/javascripts/content_editor/extensions/division.js @@ -1,12 +1,26 @@ import { Node } from '@tiptap/core'; import { PARSE_HTML_PRIORITY_LOWEST } from '../constants'; +const getDiv = (element) => { + if (element.nodeName === 'DIV') return element; + return element.querySelector('div'); +}; + export default Node.create({ name: 'division', content: 'block*', group: 'block', defining: true, + addAttributes() { + return { + className: { + default: null, + parseHTML: (element) => getDiv(element).className || null, + }, + }; + }, + parseHTML() { return [{ tag: 'div', priority: PARSE_HTML_PRIORITY_LOWEST }]; }, diff --git a/app/assets/javascripts/content_editor/extensions/footnote_definition.js b/app/assets/javascripts/content_editor/extensions/footnote_definition.js new file mode 100644 index 00000000000..dbab0de3421 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/footnote_definition.js @@ -0,0 +1,21 @@ +import { mergeAttributes, Node } from '@tiptap/core'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; + +export default Node.create({ + name: 'footnoteDefinition', + + content: 'paragraph', + + group: 'block', + + parseHTML() { + return [ + { tag: 'section.footnotes li' }, + { tag: '.footnote-backref', priority: PARSE_HTML_PRIORITY_HIGHEST, ignore: true }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ['li', mergeAttributes(HTMLAttributes), 0]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/footnote_reference.js b/app/assets/javascripts/content_editor/extensions/footnote_reference.js new file mode 100644 index 00000000000..1ac8016f774 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/footnote_reference.js @@ -0,0 +1,37 @@ +import { Node, mergeAttributes } from '@tiptap/core'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; + +export default Node.create({ + name: 'footnoteReference', + + inline: true, + + group: 'inline', + + atom: true, + + draggable: true, + + selectable: true, + + addAttributes() { + return { + footnoteId: { + default: null, + parseHTML: (element) => element.querySelector('a').getAttribute('id'), + }, + footnoteNumber: { + default: null, + parseHTML: (element) => element.textContent, + }, + }; + }, + + parseHTML() { + return [{ tag: 'sup.footnote-ref', priority: PARSE_HTML_PRIORITY_HIGHEST }]; + }, + + renderHTML({ HTMLAttributes: { footnoteNumber, footnoteId, ...HTMLAttributes } }) { + return ['sup', mergeAttributes(HTMLAttributes), footnoteNumber]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/footnotes_section.js b/app/assets/javascripts/content_editor/extensions/footnotes_section.js new file mode 100644 index 00000000000..914a8934734 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/footnotes_section.js @@ -0,0 +1,19 @@ +import { mergeAttributes, Node } from '@tiptap/core'; + +export default Node.create({ + name: 'footnotesSection', + + content: 'footnoteDefinition+', + + group: 'block', + + isolating: true, + + parseHTML() { + return [{ tag: 'section.footnotes > ol' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['ol', mergeAttributes(HTMLAttributes, { class: 'footnotes gl-font-sm' }), 0]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/html_marks.js b/app/assets/javascripts/content_editor/extensions/html_marks.js index 3abf0e3eee2..9579f3b06f6 100644 --- a/app/assets/javascripts/content_editor/extensions/html_marks.js +++ b/app/assets/javascripts/content_editor/extensions/html_marks.js @@ -31,13 +31,12 @@ const attrs = { export default marks.map((name) => Mark.create({ name, - inclusive: false, - - defaultOptions: { - HTMLAttributes: {}, + addOptions() { + return { + HTMLAttributes: {}, + }; }, - addAttributes() { return (attrs[name] || []).reduce( (acc, attr) => ({ diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js index 837fab0585f..d7fb617f7ee 100644 --- a/app/assets/javascripts/content_editor/extensions/image.js +++ b/app/assets/javascripts/content_editor/extensions/image.js @@ -7,9 +7,11 @@ const resolveImageEl = (element) => element.nodeName === 'IMG' ? element : element.querySelector('img'); export default Image.extend({ - defaultOptions: { - ...Image.options, - inline: true, + addOptions() { + return { + ...this.parent?.(), + inline: true, + }; }, addAttributes() { return { diff --git a/app/assets/javascripts/content_editor/extensions/inline_diff.js b/app/assets/javascripts/content_editor/extensions/inline_diff.js index 22bb1ac072e..f76943a0669 100644 --- a/app/assets/javascripts/content_editor/extensions/inline_diff.js +++ b/app/assets/javascripts/content_editor/extensions/inline_diff.js @@ -3,8 +3,10 @@ import { Mark, markInputRule, mergeAttributes } from '@tiptap/core'; export default Mark.create({ name: 'inlineDiff', - defaultOptions: { - HTMLAttributes: {}, + addOptions() { + return { + HTMLAttributes: {}, + }; }, addAttributes() { diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js index 27bc05dce6f..f9b12f631fe 100644 --- a/app/assets/javascripts/content_editor/extensions/link.js +++ b/app/assets/javascripts/content_editor/extensions/link.js @@ -18,10 +18,13 @@ export const extractHrefFromMarkdownLink = (match) => { }; export default Link.extend({ - defaultOptions: { - ...Link.options, - openOnClick: false, + addOptions() { + return { + ...this.parent?.(), + openOnClick: false, + }; }, + addInputRules() { const markdownLinkSyntaxInputRuleRegExp = /(?:^|\s)\[([\w|\s|-]+)\]\((?<href>.+?)\)$/gm; const urlSyntaxRegExp = /(?:^|\s)(?<href>(?:https?:\/\/|www\.)[\S]+)(?:\s|\n)$/gim; diff --git a/app/assets/javascripts/content_editor/extensions/task_item.js b/app/assets/javascripts/content_editor/extensions/task_item.js index 9b050edcb28..6efef3f8198 100644 --- a/app/assets/javascripts/content_editor/extensions/task_item.js +++ b/app/assets/javascripts/content_editor/extensions/task_item.js @@ -2,9 +2,11 @@ import { TaskItem } from '@tiptap/extension-task-item'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; export default TaskItem.extend({ - defaultOptions: { - nested: true, - HTMLAttributes: {}, + addOptions() { + return { + nested: true, + HTMLAttributes: {}, + }; }, addAttributes() { diff --git a/app/assets/javascripts/content_editor/extensions/video.js b/app/assets/javascripts/content_editor/extensions/video.js index 9923b7c04cd..312e8cd5ff6 100644 --- a/app/assets/javascripts/content_editor/extensions/video.js +++ b/app/assets/javascripts/content_editor/extensions/video.js @@ -2,9 +2,11 @@ import Playable from './playable'; export default Playable.extend({ name: 'video', - defaultOptions: { - ...Playable.options, - mediaType: 'video', - extraElementAttrs: { width: '400' }, + addOptions() { + return { + ...this.parent?.(), + mediaType: 'video', + extraElementAttrs: { width: '400' }, + }; }, }); diff --git a/app/assets/javascripts/content_editor/extensions/word_break.js b/app/assets/javascripts/content_editor/extensions/word_break.js index fa7e02f8cc8..457b7c36564 100644 --- a/app/assets/javascripts/content_editor/extensions/word_break.js +++ b/app/assets/javascripts/content_editor/extensions/word_break.js @@ -7,10 +7,12 @@ export default Node.create({ selectable: false, atom: true, - defaultOptions: { - HTMLAttributes: { - class: 'gl-display-inline-flex gl-px-1 gl-bg-blue-100 gl-rounded-base gl-font-sm', - }, + addOptions() { + return { + HTMLAttributes: { + class: 'gl-display-inline-flex gl-px-1 gl-bg-blue-100 gl-rounded-base gl-font-sm', + }, + }; }, parseHTML() { diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js index 385f1c63801..f451357e211 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -19,6 +19,9 @@ import Dropcursor from '../extensions/dropcursor'; import Emoji from '../extensions/emoji'; import Figure from '../extensions/figure'; import FigureCaption from '../extensions/figure_caption'; +import FootnoteDefinition from '../extensions/footnote_definition'; +import FootnoteReference from '../extensions/footnote_reference'; +import FootnotesSection from '../extensions/footnotes_section'; import Frontmatter from '../extensions/frontmatter'; import Gapcursor from '../extensions/gapcursor'; import HardBreak from '../extensions/hard_break'; @@ -94,6 +97,9 @@ export const createContentEditor = ({ Emoji, Figure, FigureCaption, + FootnoteDefinition, + FootnoteReference, + FootnotesSection, Frontmatter, Gapcursor, HardBreak, diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index 0dd3cb5b73f..278ef326c7a 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -17,6 +17,9 @@ import Division from '../extensions/division'; import Emoji from '../extensions/emoji'; import Figure from '../extensions/figure'; import FigureCaption from '../extensions/figure_caption'; +import FootnotesSection from '../extensions/footnotes_section'; +import FootnoteDefinition from '../extensions/footnote_definition'; +import FootnoteReference from '../extensions/footnote_reference'; import Frontmatter from '../extensions/frontmatter'; import HardBreak from '../extensions/hard_break'; import Heading from '../extensions/heading'; @@ -135,7 +138,16 @@ const defaultSerializerConfig = { state.write('```'); state.closeBlock(node); }, - [Division.name]: renderHTMLNode('div'), + [Division.name]: (state, node) => { + if (node.attrs.className?.includes('js-markdown-code')) { + state.renderInline(node); + } else { + const newNode = node; + delete newNode.attrs.className; + + renderHTMLNode('div')(state, newNode); + } + }, [DescriptionList.name]: renderHTMLNode('dl', true), [DescriptionItem.name]: (state, node, parent, index) => { if (index === 1) state.ensureNewLine(); @@ -156,6 +168,15 @@ const defaultSerializerConfig = { state.write(`:${name}:`); }, + [FootnoteDefinition.name]: (state, node) => { + state.renderInline(node); + }, + [FootnoteReference.name]: (state, node) => { + state.write(`[^${node.attrs.footnoteNumber}]`); + }, + [FootnotesSection.name]: (state, node) => { + state.renderList(node, '', (index) => `[^${index + 1}]: `); + }, [Frontmatter.name]: (state, node) => { const { language } = node.attrs; const syntax = { diff --git a/app/assets/javascripts/crm/components/contact_form.vue b/app/assets/javascripts/crm/components/contact_form.vue new file mode 100644 index 00000000000..81ae5c246be --- /dev/null +++ b/app/assets/javascripts/crm/components/contact_form.vue @@ -0,0 +1,224 @@ +<script> +import { GlAlert, GlButton, GlDrawer, GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { produce } from 'immer'; +import { __, s__ } from '~/locale'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_GROUP } from '~/graphql_shared/constants'; +import createContactMutation from './queries/create_contact.mutation.graphql'; +import updateContactMutation from './queries/update_contact.mutation.graphql'; +import getGroupContactsQuery from './queries/get_group_contacts.query.graphql'; + +export default { + components: { + GlAlert, + GlButton, + GlDrawer, + GlFormGroup, + GlFormInput, + }, + inject: ['groupFullPath', 'groupId'], + props: { + drawerOpen: { + type: Boolean, + required: true, + }, + contact: { + type: Object, + required: false, + default: () => {}, + }, + }, + data() { + return { + firstName: '', + lastName: '', + phone: '', + email: '', + description: '', + submitting: false, + errorMessages: [], + }; + }, + computed: { + invalid() { + const { firstName, lastName, email } = this; + + return firstName.trim() === '' || lastName.trim() === '' || email.trim() === ''; + }, + editMode() { + return Boolean(this.contact); + }, + title() { + return this.editMode ? this.$options.i18n.editTitle : this.$options.i18n.newTitle; + }, + buttonLabel() { + return this.editMode + ? this.$options.i18n.editButtonLabel + : this.$options.i18n.createButtonLabel; + }, + mutation() { + return this.editMode ? updateContactMutation : createContactMutation; + }, + variables() { + const { contact, firstName, lastName, phone, email, description, editMode, groupId } = this; + + const variables = { + input: { + firstName, + lastName, + phone, + email, + description, + }, + }; + + if (editMode) { + variables.input.id = contact.id; + } else { + variables.input.groupId = convertToGraphQLId(TYPE_GROUP, groupId); + } + + return variables; + }, + }, + mounted() { + if (this.editMode) { + const { contact } = this; + + this.firstName = contact.firstName || ''; + this.lastName = contact.lastName || ''; + this.phone = contact.phone || ''; + this.email = contact.email || ''; + this.description = contact.description || ''; + } + }, + methods: { + save() { + const { mutation, variables, updateCache, close } = this; + + this.submitting = true; + + return this.$apollo + .mutate({ + mutation, + variables, + update: updateCache, + }) + .then(({ data }) => { + if ( + data.customerRelationsContactCreate?.errors.length === 0 || + data.customerRelationsContactUpdate?.errors.length === 0 + ) { + close(true); + } + + this.submitting = false; + }) + .catch(() => { + this.errorMessages = [this.$options.i18n.somethingWentWrong]; + this.submitting = false; + }); + }, + close(success) { + this.$emit('close', success); + }, + updateCache(store, { data }) { + const mutationData = + data.customerRelationsContactCreate || data.customerRelationsContactUpdate; + + if (mutationData?.errors.length > 0) { + this.errorMessages = mutationData.errors; + return; + } + + const queryArgs = { + query: getGroupContactsQuery, + variables: { groupFullPath: this.groupFullPath }, + }; + + const sourceData = store.readQuery(queryArgs); + + queryArgs.data = produce(sourceData, (draftState) => { + draftState.group.contacts.nodes = [ + ...sourceData.group.contacts.nodes.filter(({ id }) => id !== this.contact?.id), + mutationData.contact, + ]; + }); + + store.writeQuery(queryArgs); + }, + getDrawerHeaderHeight() { + const wrapperEl = document.querySelector('.content-wrapper'); + + if (wrapperEl) { + return `${wrapperEl.offsetTop}px`; + } + + return ''; + }, + }, + i18n: { + createButtonLabel: s__('Crm|Create new contact'), + editButtonLabel: __('Save changes'), + cancel: __('Cancel'), + firstName: s__('Crm|First name'), + lastName: s__('Crm|Last name'), + email: s__('Crm|Email'), + phone: s__('Crm|Phone number (optional)'), + description: s__('Crm|Description (optional)'), + newTitle: s__('Crm|New contact'), + editTitle: s__('Crm|Edit contact'), + somethingWentWrong: __('Something went wrong. Please try again.'), + }, +}; +</script> + +<template> + <gl-drawer + class="gl-drawer-responsive" + :open="drawerOpen" + :header-height="getDrawerHeaderHeight()" + @close="close(false)" + > + <template #title> + <h3>{{ title }}</h3> + </template> + <gl-alert v-if="errorMessages.length" variant="danger" @dismiss="errorMessages = []"> + <ul class="gl-mb-0! gl-ml-5"> + <li v-for="error in errorMessages" :key="error"> + {{ error }} + </li> + </ul> + </gl-alert> + <form @submit.prevent="save"> + <gl-form-group :label="$options.i18n.firstName" label-for="contact-first-name"> + <gl-form-input id="contact-first-name" v-model="firstName" /> + </gl-form-group> + <gl-form-group :label="$options.i18n.lastName" label-for="contact-last-name"> + <gl-form-input id="contact-last-name" v-model="lastName" /> + </gl-form-group> + <gl-form-group :label="$options.i18n.email" label-for="contact-email"> + <gl-form-input id="contact-email" v-model="email" /> + </gl-form-group> + <gl-form-group :label="$options.i18n.phone" label-for="contact-phone"> + <gl-form-input id="contact-phone" v-model="phone" /> + </gl-form-group> + <gl-form-group :label="$options.i18n.description" label-for="contact-description"> + <gl-form-input id="contact-description" v-model="description" /> + </gl-form-group> + <span class="gl-float-right"> + <gl-button data-testid="cancel-button" @click="close(false)"> + {{ $options.i18n.cancel }} + </gl-button> + <gl-button + variant="confirm" + :disabled="invalid" + :loading="submitting" + data-testid="save-contact-button" + type="submit" + >{{ buttonLabel }}</gl-button + > + </span> + </form> + </gl-drawer> +</template> diff --git a/app/assets/javascripts/crm/components/contacts_root.vue b/app/assets/javascripts/crm/components/contacts_root.vue index 83c02f7d5fe..178ce84c64d 100644 --- a/app/assets/javascripts/crm/components/contacts_root.vue +++ b/app/assets/javascripts/crm/components/contacts_root.vue @@ -1,17 +1,30 @@ <script> -import { GlLoadingIcon, GlTable } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui'; +import { parseBoolean } from '~/lib/utils/common_utils'; import { s__, __ } from '~/locale'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_CRM_CONTACT } from '~/graphql_shared/constants'; +import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from '../constants'; import getGroupContactsQuery from './queries/get_group_contacts.query.graphql'; +import ContactForm from './contact_form.vue'; export default { components: { + GlAlert, + GlButton, GlLoadingIcon, GlTable, + ContactForm, }, - inject: ['groupFullPath'], + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: ['groupFullPath', 'groupIssuesPath', 'canAdminCrmContact'], data() { - return { contacts: [] }; + return { + contacts: [], + error: false, + }; }, apollo: { contacts: { @@ -26,12 +39,8 @@ export default { update(data) { return this.extractContacts(data); }, - error(error) { - createFlash({ - message: __('Something went wrong. Please try again.'), - error, - captureError: true, - }); + error() { + this.error = true; }, }, }, @@ -39,12 +48,51 @@ export default { isLoading() { return this.$apollo.queries.contacts.loading; }, + showNewForm() { + return this.$route.name === NEW_ROUTE_NAME; + }, + showEditForm() { + return !this.isLoading && this.$route.name === EDIT_ROUTE_NAME; + }, + canAdmin() { + return parseBoolean(this.canAdminCrmContact); + }, + editingContact() { + return this.contacts.find( + (contact) => contact.id === convertToGraphQLId(TYPE_CRM_CONTACT, this.$route.params.id), + ); + }, }, methods: { extractContacts(data) { const contacts = data?.group?.contacts?.nodes || []; return contacts.slice().sort((a, b) => a.firstName.localeCompare(b.firstName)); }, + displayNewForm() { + if (this.showNewForm) return; + + this.$router.push({ name: NEW_ROUTE_NAME }); + }, + hideNewForm(success) { + if (success) this.$toast.show(s__('Crm|Contact has been added')); + + this.$router.replace({ name: INDEX_ROUTE_NAME }); + }, + hideEditForm(success) { + if (success) this.$toast.show(s__('Crm|Contact has been updated')); + + this.editingContactId = 0; + this.$router.replace({ name: INDEX_ROUTE_NAME }); + }, + getIssuesPath(path, value) { + return `${path}?scope=all&state=opened&crm_contact_id=${value}`; + }, + edit(value) { + if (this.showEditForm) return; + + this.editingContactId = value; + this.$router.push({ name: EDIT_ROUTE_NAME, params: { id: value } }); + }, }, fields: [ { key: 'firstName', sortable: true }, @@ -59,22 +107,81 @@ export default { }, sortable: true, }, + { + key: 'id', + label: '', + formatter: (id) => { + return getIdFromGraphQLId(id); + }, + }, ], i18n: { emptyText: s__('Crm|No contacts found'), + issuesButtonLabel: __('View issues'), + editButtonLabel: __('Edit'), + title: s__('Crm|Customer Relations Contacts'), + newContact: s__('Crm|New contact'), + errorText: __('Something went wrong. Please try again.'), }, }; </script> <template> <div> + <gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="error = false"> + {{ $options.i18n.errorText }} + </gl-alert> + <div + class="gl-display-flex gl-align-items-baseline gl-flex-direction-row gl-justify-content-space-between gl-mt-6" + > + <h2 class="gl-font-size-h2 gl-my-0"> + {{ $options.i18n.title }} + </h2> + <div class="gl-display-none gl-md-display-flex gl-align-items-center gl-justify-content-end"> + <gl-button + v-if="canAdmin" + variant="confirm" + data-testid="new-contact-button" + @click="displayNewForm" + > + {{ $options.i18n.newContact }} + </gl-button> + </div> + </div> + <contact-form v-if="showNewForm" :drawer-open="showNewForm" @close="hideNewForm" /> + <contact-form + v-if="showEditForm" + :contact="editingContact" + :drawer-open="showEditForm" + @close="hideEditForm" + /> <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" /> <gl-table v-else + class="gl-mt-5" :items="contacts" :fields="$options.fields" :empty-text="$options.i18n.emptyText" show-empty - /> + > + <template #cell(id)="data"> + <gl-button + v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel" + class="gl-mr-3" + data-testid="issues-link" + icon="issues" + :aria-label="$options.i18n.issuesButtonLabel" + :href="getIssuesPath(groupIssuesPath, data.value)" + /> + <gl-button + v-if="canAdmin" + v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel" + data-testid="edit-contact-button" + icon="pencil" + :aria-label="$options.i18n.editButtonLabel" + @click="edit(data.value)" + /> + </template> + </gl-table> </div> </template> diff --git a/app/assets/javascripts/crm/components/new_organization_form.vue b/app/assets/javascripts/crm/components/new_organization_form.vue new file mode 100644 index 00000000000..3b11edc6935 --- /dev/null +++ b/app/assets/javascripts/crm/components/new_organization_form.vue @@ -0,0 +1,164 @@ +<script> +import { GlAlert, GlButton, GlDrawer, GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { produce } from 'immer'; +import { __, s__ } from '~/locale'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_GROUP } from '~/graphql_shared/constants'; +import createOrganization from './queries/create_organization.mutation.graphql'; +import getGroupOrganizationsQuery from './queries/get_group_organizations.query.graphql'; + +export default { + components: { + GlAlert, + GlButton, + GlDrawer, + GlFormGroup, + GlFormInput, + }, + inject: ['groupFullPath', 'groupId'], + props: { + drawerOpen: { + type: Boolean, + required: true, + }, + }, + data() { + return { + name: '', + defaultRate: null, + description: '', + submitting: false, + errorMessages: [], + }; + }, + computed: { + invalid() { + return this.name.trim() === ''; + }, + }, + methods: { + save() { + this.submitting = true; + return this.$apollo + .mutate({ + mutation: createOrganization, + variables: { + input: { + groupId: convertToGraphQLId(TYPE_GROUP, this.groupId), + name: this.name, + defaultRate: this.defaultRate ? parseFloat(this.defaultRate) : null, + description: this.description, + }, + }, + update: this.updateCache, + }) + .then(({ data }) => { + if (data.customerRelationsOrganizationCreate.errors.length === 0) this.close(true); + + this.submitting = false; + }) + .catch(() => { + this.errorMessages = [this.$options.i18n.somethingWentWrong]; + this.submitting = false; + }); + }, + close(success) { + this.$emit('close', success); + }, + updateCache(store, { data: { customerRelationsOrganizationCreate } }) { + if (customerRelationsOrganizationCreate.errors.length > 0) { + this.errorMessages = customerRelationsOrganizationCreate.errors; + return; + } + + const variables = { + groupFullPath: this.groupFullPath, + }; + const sourceData = store.readQuery({ + query: getGroupOrganizationsQuery, + variables, + }); + + const data = produce(sourceData, (draftState) => { + draftState.group.organizations.nodes = [ + ...sourceData.group.organizations.nodes, + customerRelationsOrganizationCreate.organization, + ]; + }); + + store.writeQuery({ + query: getGroupOrganizationsQuery, + variables, + data, + }); + }, + getDrawerHeaderHeight() { + const wrapperEl = document.querySelector('.content-wrapper'); + + if (wrapperEl) { + return `${wrapperEl.offsetTop}px`; + } + + return ''; + }, + }, + i18n: { + buttonLabel: s__('Crm|Create organization'), + cancel: __('Cancel'), + name: __('Name'), + defaultRate: s__('Crm|Default rate (optional)'), + description: __('Description (optional)'), + title: s__('Crm|New Organization'), + somethingWentWrong: __('Something went wrong. Please try again.'), + }, +}; +</script> + +<template> + <gl-drawer + class="gl-drawer-responsive" + :open="drawerOpen" + :header-height="getDrawerHeaderHeight()" + @close="close(false)" + > + <template #title> + <h4>{{ $options.i18n.title }}</h4> + </template> + <gl-alert v-if="errorMessages.length" variant="danger" @dismiss="errorMessages = []"> + <ul class="gl-mb-0! gl-ml-5"> + <li v-for="error in errorMessages" :key="error"> + {{ error }} + </li> + </ul> + </gl-alert> + <form @submit.prevent="save"> + <gl-form-group :label="$options.i18n.name" label-for="organization-name"> + <gl-form-input id="organization-name" v-model="name" /> + </gl-form-group> + <gl-form-group :label="$options.i18n.defaultRate" label-for="organization-default-rate"> + <gl-form-input + id="organization-default-rate" + v-model="defaultRate" + type="number" + step="0.01" + /> + </gl-form-group> + <gl-form-group :label="$options.i18n.description" label-for="organization-description"> + <gl-form-input id="organization-description" v-model="description" /> + </gl-form-group> + <span class="gl-float-right"> + <gl-button data-testid="cancel-button" @click="close(false)"> + {{ $options.i18n.cancel }} + </gl-button> + <gl-button + variant="confirm" + :disabled="invalid" + :loading="submitting" + data-testid="create-new-organization-button" + type="submit" + >{{ $options.i18n.buttonLabel }}</gl-button + > + </span> + </form> + </gl-drawer> +</template> diff --git a/app/assets/javascripts/crm/components/organizations_root.vue b/app/assets/javascripts/crm/components/organizations_root.vue index 98b45d0a042..9370c6377e9 100644 --- a/app/assets/javascripts/crm/components/organizations_root.vue +++ b/app/assets/javascripts/crm/components/organizations_root.vue @@ -1,17 +1,29 @@ <script> -import { GlLoadingIcon, GlTable } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui'; +import { parseBoolean } from '~/lib/utils/common_utils'; import { s__, __ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME } from '../constants'; import getGroupOrganizationsQuery from './queries/get_group_organizations.query.graphql'; +import NewOrganizationForm from './new_organization_form.vue'; export default { components: { + GlAlert, + GlButton, GlLoadingIcon, GlTable, + NewOrganizationForm, }, - inject: ['groupFullPath'], + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: ['canAdminCrmOrganization', 'groupFullPath', 'groupIssuesPath'], data() { - return { organizations: [] }; + return { + error: false, + organizations: [], + }; }, apollo: { organizations: { @@ -26,12 +38,8 @@ export default { update(data) { return this.extractOrganizations(data); }, - error(error) { - createFlash({ - message: __('Something went wrong. Please try again.'), - error, - captureError: true, - }); + error() { + this.error = true; }, }, }, @@ -39,33 +47,94 @@ export default { isLoading() { return this.$apollo.queries.organizations.loading; }, + showNewForm() { + return this.$route.name === NEW_ROUTE_NAME; + }, + canCreateNew() { + return parseBoolean(this.canAdminCrmOrganization); + }, }, methods: { extractOrganizations(data) { const organizations = data?.group?.organizations?.nodes || []; return organizations.slice().sort((a, b) => a.name.localeCompare(b.name)); }, + getIssuesPath(path, value) { + return `${path}?scope=all&state=opened&crm_organization_id=${value}`; + }, + displayNewForm() { + if (this.showNewForm) return; + + this.$router.push({ name: NEW_ROUTE_NAME }); + }, + hideNewForm(success) { + if (success) this.$toast.show(this.$options.i18n.organizationAdded); + + this.$router.replace({ name: INDEX_ROUTE_NAME }); + }, }, fields: [ { key: 'name', sortable: true }, { key: 'defaultRate', sortable: true }, { key: 'description', sortable: true }, + { + key: 'id', + label: __('Issues'), + formatter: (id) => { + return getIdFromGraphQLId(id); + }, + }, ], i18n: { emptyText: s__('Crm|No organizations found'), + issuesButtonLabel: __('View issues'), + title: s__('Crm|Customer Relations Organizations'), + newOrganization: s__('Crm|New organization'), + errorText: __('Something went wrong. Please try again.'), + organizationAdded: s__('Crm|Organization has been added'), }, }; </script> <template> <div> + <gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="error = false"> + {{ $options.i18n.errorText }} + </gl-alert> + <div + class="gl-display-flex gl-align-items-baseline gl-flex-direction-row gl-justify-content-space-between gl-mt-6" + > + <h2 class="gl-font-size-h2 gl-my-0"> + {{ $options.i18n.title }} + </h2> + <div + v-if="canCreateNew" + class="gl-display-none gl-md-display-flex gl-align-items-center gl-justify-content-end" + > + <gl-button variant="confirm" data-testid="new-organization-button" @click="displayNewForm"> + {{ $options.i18n.newOrganization }} + </gl-button> + </div> + </div> + <new-organization-form v-if="showNewForm" :drawer-open="showNewForm" @close="hideNewForm" /> <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" /> <gl-table v-else + class="gl-mt-5" :items="organizations" :fields="$options.fields" :empty-text="$options.i18n.emptyText" show-empty - /> + > + <template #cell(id)="data"> + <gl-button + v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel" + data-testid="issues-link" + icon="issues" + :aria-label="$options.i18n.issuesButtonLabel" + :href="getIssuesPath(groupIssuesPath, data.value)" + /> + </template> + </gl-table> </div> </template> diff --git a/app/assets/javascripts/crm/components/queries/create_contact.mutation.graphql b/app/assets/javascripts/crm/components/queries/create_contact.mutation.graphql new file mode 100644 index 00000000000..e0192459609 --- /dev/null +++ b/app/assets/javascripts/crm/components/queries/create_contact.mutation.graphql @@ -0,0 +1,10 @@ +#import "./crm_contact_fields.fragment.graphql" + +mutation createContact($input: CustomerRelationsContactCreateInput!) { + customerRelationsContactCreate(input: $input) { + contact { + ...ContactFragment + } + errors + } +} diff --git a/app/assets/javascripts/crm/components/queries/create_organization.mutation.graphql b/app/assets/javascripts/crm/components/queries/create_organization.mutation.graphql new file mode 100644 index 00000000000..2cc7e53ee9b --- /dev/null +++ b/app/assets/javascripts/crm/components/queries/create_organization.mutation.graphql @@ -0,0 +1,10 @@ +#import "./crm_organization_fields.fragment.graphql" + +mutation createOrganization($input: CustomerRelationsOrganizationCreateInput!) { + customerRelationsOrganizationCreate(input: $input) { + organization { + ...OrganizationFragment + } + errors + } +} diff --git a/app/assets/javascripts/crm/components/queries/crm_contact_fields.fragment.graphql b/app/assets/javascripts/crm/components/queries/crm_contact_fields.fragment.graphql new file mode 100644 index 00000000000..cef4083b446 --- /dev/null +++ b/app/assets/javascripts/crm/components/queries/crm_contact_fields.fragment.graphql @@ -0,0 +1,14 @@ +fragment ContactFragment on CustomerRelationsContact { + __typename + id + firstName + lastName + email + phone + description + organization { + __typename + id + name + } +} diff --git a/app/assets/javascripts/crm/components/queries/crm_organization_fields.fragment.graphql b/app/assets/javascripts/crm/components/queries/crm_organization_fields.fragment.graphql new file mode 100644 index 00000000000..4adc5742d3a --- /dev/null +++ b/app/assets/javascripts/crm/components/queries/crm_organization_fields.fragment.graphql @@ -0,0 +1,7 @@ +fragment OrganizationFragment on CustomerRelationsOrganization { + __typename + id + name + defaultRate + description +} diff --git a/app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql b/app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql index f6acd258585..2a8150e42e3 100644 --- a/app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql +++ b/app/assets/javascripts/crm/components/queries/get_group_contacts.query.graphql @@ -1,21 +1,12 @@ +#import "./crm_contact_fields.fragment.graphql" + query contacts($groupFullPath: ID!) { group(fullPath: $groupFullPath) { __typename id contacts { nodes { - __typename - id - firstName - lastName - email - phone - description - organization { - __typename - id - name - } + ...ContactFragment } } } diff --git a/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql b/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql index 7c4ec6ec585..e8d8109431e 100644 --- a/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql +++ b/app/assets/javascripts/crm/components/queries/get_group_organizations.query.graphql @@ -1,14 +1,12 @@ +#import "./crm_organization_fields.fragment.graphql" + query organizations($groupFullPath: ID!) { group(fullPath: $groupFullPath) { __typename id organizations { nodes { - __typename - id - name - defaultRate - description + ...OrganizationFragment } } } diff --git a/app/assets/javascripts/crm/components/queries/update_contact.mutation.graphql b/app/assets/javascripts/crm/components/queries/update_contact.mutation.graphql new file mode 100644 index 00000000000..f55f6a10e0a --- /dev/null +++ b/app/assets/javascripts/crm/components/queries/update_contact.mutation.graphql @@ -0,0 +1,10 @@ +#import "./crm_contact_fields.fragment.graphql" + +mutation updateContact($input: CustomerRelationsContactUpdateInput!) { + customerRelationsContactUpdate(input: $input) { + contact { + ...ContactFragment + } + errors + } +} diff --git a/app/assets/javascripts/crm/constants.js b/app/assets/javascripts/crm/constants.js new file mode 100644 index 00000000000..3b085837aea --- /dev/null +++ b/app/assets/javascripts/crm/constants.js @@ -0,0 +1,3 @@ +export const INDEX_ROUTE_NAME = 'index'; +export const NEW_ROUTE_NAME = 'new'; +export const EDIT_ROUTE_NAME = 'edit'; diff --git a/app/assets/javascripts/crm/contacts_bundle.js b/app/assets/javascripts/crm/contacts_bundle.js index 6438953596e..f49ec64210f 100644 --- a/app/assets/javascripts/crm/contacts_bundle.js +++ b/app/assets/javascripts/crm/contacts_bundle.js @@ -1,9 +1,14 @@ +import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; import createDefaultClient from '~/lib/graphql'; import CrmContactsRoot from './components/contacts_root.vue'; +import routes from './routes'; Vue.use(VueApollo); +Vue.use(VueRouter); +Vue.use(GlToast); export default () => { const el = document.getElementById('js-crm-contacts-app'); @@ -16,10 +21,19 @@ export default () => { return false; } + const { basePath, groupFullPath, groupIssuesPath, canAdminCrmContact, groupId } = el.dataset; + + const router = new VueRouter({ + base: basePath, + mode: 'history', + routes, + }); + return new Vue({ el, + router, apolloProvider, - provide: { groupFullPath: el.dataset.groupFullPath }, + provide: { groupFullPath, groupIssuesPath, canAdminCrmContact, groupId }, render(createElement) { return createElement(CrmContactsRoot); }, diff --git a/app/assets/javascripts/crm/organizations_bundle.js b/app/assets/javascripts/crm/organizations_bundle.js index ac9990b9fb4..828d7cd426c 100644 --- a/app/assets/javascripts/crm/organizations_bundle.js +++ b/app/assets/javascripts/crm/organizations_bundle.js @@ -1,9 +1,14 @@ +import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; import createDefaultClient from '~/lib/graphql'; import CrmOrganizationsRoot from './components/organizations_root.vue'; +import routes from './routes'; Vue.use(VueApollo); +Vue.use(VueRouter); +Vue.use(GlToast); export default () => { const el = document.getElementById('js-crm-organizations-app'); @@ -16,10 +21,19 @@ export default () => { return false; } + const { basePath, canAdminCrmOrganization, groupFullPath, groupId, groupIssuesPath } = el.dataset; + + const router = new VueRouter({ + base: basePath, + mode: 'history', + routes, + }); + return new Vue({ el, + router, apolloProvider, - provide: { groupFullPath: el.dataset.groupFullPath }, + provide: { canAdminCrmOrganization, groupFullPath, groupId, groupIssuesPath }, render(createElement) { return createElement(CrmOrganizationsRoot); }, diff --git a/app/assets/javascripts/crm/routes.js b/app/assets/javascripts/crm/routes.js new file mode 100644 index 00000000000..12aa17d73b6 --- /dev/null +++ b/app/assets/javascripts/crm/routes.js @@ -0,0 +1,16 @@ +import { INDEX_ROUTE_NAME, NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from './constants'; + +export default [ + { + name: INDEX_ROUTE_NAME, + path: '/', + }, + { + name: NEW_ROUTE_NAME, + path: '/new', + }, + { + name: EDIT_ROUTE_NAME, + path: '/:id/edit', + }, +]; diff --git a/app/assets/javascripts/delete_label_modal.js b/app/assets/javascripts/delete_label_modal.js deleted file mode 100644 index cf7c9e7734f..00000000000 --- a/app/assets/javascripts/delete_label_modal.js +++ /dev/null @@ -1,16 +0,0 @@ -import Vue from 'vue'; -import DeleteLabelModal from '~/vue_shared/components/delete_label_modal.vue'; - -const mountDeleteLabelModal = (optionalProps) => - new Vue({ - render(h) { - return h(DeleteLabelModal, { - props: { - selector: '.js-delete-label-modal-button', - ...optionalProps, - }, - }); - }, - }).$mount(); - -export default (optionalProps = {}) => mountDeleteLabelModal(optionalProps); diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue index 813f87452d8..10976202d06 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue @@ -1,11 +1,12 @@ <script> -import { GlTooltipDirective, GlIcon, GlLoadingIcon, GlLink, GlBadge } from '@gitlab/ui'; +import { GlTooltipDirective, GlIcon, GlLoadingIcon, GlLink } from '@gitlab/ui'; import { ApolloMutation } from 'vue-apollo'; import createFlash from '~/flash'; import { s__ } from '~/locale'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; import { updateGlobalTodoCount } from '~/vue_shared/components/sidebar/todo_toggle/utils'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue'; import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants'; import createNoteMutation from '../../graphql/mutations/create_note.mutation.graphql'; import toggleResolveDiscussionMutation from '../../graphql/mutations/toggle_resolve_discussion.mutation.graphql'; @@ -30,7 +31,7 @@ export default { GlLink, ToggleRepliesWidget, TimeAgoTooltip, - GlBadge, + DesignNotePin, }, directives: { GlTooltip: GlTooltipDirective, @@ -213,12 +214,7 @@ export default { <template> <div class="design-discussion-wrapper"> - <gl-badge - class="gl-display-flex gl-align-items-center gl-justify-content-center gl-cursor-pointer" - :class="{ resolved: discussion.resolved }" - > - {{ discussion.index }} - </gl-badge> + <design-note-pin :is-resolved="discussion.resolved" :label="discussion.index" /> <ul class="design-discussion bordered-box gl-relative gl-p-0 gl-list-style-none" data-qa-selector="design_discussion_content" diff --git a/app/assets/javascripts/design_management/components/design_overlay.vue b/app/assets/javascripts/design_management/components/design_overlay.vue index 7815a57ce18..b058709b316 100644 --- a/app/assets/javascripts/design_management/components/design_overlay.vue +++ b/app/assets/javascripts/design_management/components/design_overlay.vue @@ -1,9 +1,9 @@ <script> import { __ } from '~/locale'; +import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue'; import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants'; import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql'; import activeDiscussionQuery from '../graphql/queries/active_discussion.query.graphql'; -import DesignNotePin from './design_note_pin.vue'; export default { name: 'DesignOverlay', @@ -251,9 +251,6 @@ export default { !discussionNotes.some(({ id }) => id === this.activeDiscussion.id) ); }, - designPinClass(note) { - return { inactive: this.isNoteInactive(note), resolved: note.resolved }; - }, }, i18n: { newCommentButtonLabel: __('Add comment to design'), @@ -287,7 +284,8 @@ export default { ? getNotePositionStyle(movingNoteNewPosition) : getNotePositionStyle(note.position) " - :class="designPinClass(note)" + :is-inactive="isNoteInactive(note)" + :is-resolved="note.resolved" @mousedown.stop="onNoteMousedown($event, note)" @mouseup.stop="onNoteMouseup(note)" /> diff --git a/app/assets/javascripts/design_management/graphql/fragments/discussion_resolved_status.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/discussion_resolved_status.fragment.graphql index 7483b508721..9ad85017921 100644 --- a/app/assets/javascripts/design_management/graphql/fragments/discussion_resolved_status.fragment.graphql +++ b/app/assets/javascripts/design_management/graphql/fragments/discussion_resolved_status.fragment.graphql @@ -1,8 +1,10 @@ fragment ResolvedStatus on Discussion { + id resolvable resolved resolvedAt resolvedBy { + id name webUrl } diff --git a/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql index 111f5ac18a7..34d683ac1ee 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql @@ -3,6 +3,7 @@ mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) { designManagementUpload(input: { projectPath: $projectPath, iid: $iid, files: $files }) { + # eslint-disable-next-line @graphql-eslint/require-id-when-available designs { ...DesignItem versions { @@ -14,6 +15,7 @@ mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) { } } skippedDesigns { + id filename } errors diff --git a/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql index 99a61191c6e..a5394457f73 100644 --- a/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql +++ b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql @@ -10,8 +10,10 @@ query getDesign( project(fullPath: $fullPath) { id issue(iid: $iid) { + id designCollection { designs(atVersion: $atVersion, filenames: $filenames) { + # eslint-disable-next-line @graphql-eslint/require-id-when-available nodes { ...DesignItem issue { diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index f405b82b05b..66d06a3a1b6 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -44,7 +44,6 @@ import { TRACKING_MULTIPLE_FILES_MODE, } from '../constants'; -import { discussionIntersectionObserverHandlerFactory } from '../utils/discussions'; import diffsEventHub from '../event_hub'; import { reviewStatuses } from '../utils/file_reviews'; import { diffsApp } from '../utils/performance'; @@ -87,9 +86,6 @@ export default { ALERT_MERGE_CONFLICT, ALERT_COLLAPSED_FILES, }, - provide: { - discussionObserverHandler: discussionIntersectionObserverHandlerFactory(), - }, props: { endpoint: { type: String, diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index d09cc064b2c..4e77bf81c1e 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -10,6 +10,7 @@ import { import { escape } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; import { IdState } from 'vendor/vue-virtual-scroller'; +import DiffContent from 'jh_else_ce/diffs/components/diff_content.vue'; import createFlash from '~/flash'; import { hasDiff } from '~/helpers/diffs_helper'; import { diffViewerErrors } from '~/ide/constants'; @@ -28,7 +29,6 @@ import { import eventHub from '../event_hub'; import { DIFF_FILE, GENERIC_ERROR, CONFLICT_TEXT } from '../i18n'; import { collapsedType, getShortShaFromFile } from '../utils/diff_file'; -import DiffContent from './diff_content.vue'; import DiffFileHeader from './diff_file_header.vue'; export default { diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue index 4e33a02ca0e..4893803a3b6 100644 --- a/app/assets/javascripts/diffs/components/diff_row.vue +++ b/app/assets/javascripts/diffs/components/diff_row.vue @@ -42,6 +42,11 @@ export default { required: false, default: false, }, + coverageLoaded: { + type: Boolean, + required: false, + default: false, + }, inline: { type: Boolean, required: false, @@ -83,14 +88,15 @@ export default { if (!props.inline || !props.line.left) return {}; return props.fileLineCoverage(props.filePath, props.line.left.new_line); }, - (props) => [props.inline, props.filePath, props.line.left?.new_line].join(':'), + (props) => + [props.inline, props.filePath, props.line.left?.new_line, props.coverageLoaded].join(':'), ), coverageStateRight: memoize( (props) => { if (!props.line.right) return {}; return props.fileLineCoverage(props.filePath, props.line.right.new_line); }, - (props) => [props.line.right?.new_line, props.filePath].join(':'), + (props) => [props.line.right?.new_line, props.filePath, props.coverageLoaded].join(':'), ), showCodequalityLeft: memoize( (props) => { diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue index 55c796182ee..8562a1d44e7 100644 --- a/app/assets/javascripts/diffs/components/diff_view.vue +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -52,7 +52,7 @@ export default { }, computed: { ...mapGetters('diffs', ['commitId', 'fileLineCoverage']), - ...mapState('diffs', ['codequalityDiff', 'highlightedRow']), + ...mapState('diffs', ['codequalityDiff', 'highlightedRow', 'coverageLoaded']), ...mapState({ selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition, selectedCommentPositionHover: ({ notes }) => notes.selectedCommentPositionHover, @@ -180,6 +180,7 @@ export default { :index="index" :is-highlighted="isHighlighted(line)" :file-line-coverage="fileLineCoverage" + :coverage-loaded="coverageLoaded" @showCommentForm="(code) => singleLineComment(code, line)" @setHighlightedRow="setHighlightedRow" @toggleLineDiscussions=" diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index a5b1a577a78..5f66360a040 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -21,6 +21,7 @@ export default () => ({ startVersion: null, // Null unless a target diff is selected for comparison that is not the "base" diff diffFiles: [], coverageFiles: {}, + coverageLoaded: false, mergeRequestDiffs: [], mergeRequestDiff: null, diffViewType: getViewTypeFromQueryString() || viewTypeFromCookie || defaultViewType, diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 4a9df0eafcc..fb35114c0a9 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -86,7 +86,7 @@ export default { }, [types.SET_COVERAGE_DATA](state, coverageFiles) { - Object.assign(state, { coverageFiles }); + Object.assign(state, { coverageFiles, coverageLoaded: true }); }, [types.RENDER_FILE](state, file) { diff --git a/app/assets/javascripts/diffs/utils/discussions.js b/app/assets/javascripts/diffs/utils/discussions.js deleted file mode 100644 index c404705d209..00000000000 --- a/app/assets/javascripts/diffs/utils/discussions.js +++ /dev/null @@ -1,76 +0,0 @@ -function normalize(processable) { - const { entry } = processable; - const offset = entry.rootBounds.bottom - entry.boundingClientRect.top; - const direction = - offset < 0 ? 'Up' : 'Down'; /* eslint-disable-line @gitlab/require-i18n-strings */ - - return { - ...processable, - entry: { - time: entry.time, - type: entry.isIntersecting ? 'intersection' : `scroll${direction}`, - }, - }; -} - -function sort({ entry: alpha }, { entry: beta }) { - const diff = alpha.time - beta.time; - let order = 0; - - if (diff < 0) { - order = -1; - } else if (diff > 0) { - order = 1; - } else if (alpha.type === 'intersection' && beta.type === 'scrollUp') { - order = 2; - } else if (alpha.type === 'scrollUp' && beta.type === 'intersection') { - order = -2; - } - - return order; -} - -function filter(entry) { - return entry.type !== 'scrollDown'; -} - -export function discussionIntersectionObserverHandlerFactory() { - let unprocessed = []; - let timer = null; - - return (processable) => { - unprocessed.push(processable); - - if (timer) { - clearTimeout(timer); - } - - timer = setTimeout(() => { - unprocessed - .map(normalize) - .filter(filter) - .sort(sort) - .forEach((discussionObservationContainer) => { - const { - entry: { type }, - currentDiscussion, - isFirstUnresolved, - isDiffsPage, - functions: { setCurrentDiscussionId, getPreviousUnresolvedDiscussionId }, - } = discussionObservationContainer; - - if (type === 'intersection') { - setCurrentDiscussionId(currentDiscussion.id); - } else if (type === 'scrollUp') { - setCurrentDiscussionId( - isFirstUnresolved - ? null - : getPreviousUnresolvedDiscussionId(currentDiscussion.id, isDiffsPage), - ); - } - }); - - unprocessed = []; - }, 0); - }; -} diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index f404fa4e0e8..7c7127dfa44 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -44,6 +44,7 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { let addFileToForm; let updateAttachingMessage; let uploadFile; + let hasPlainText; formTextarea.wrap('<div class="div-dropzone"></div>'); formTextarea.on('paste', (event) => handlePaste(event)); @@ -184,7 +185,7 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { event.preventDefault(); const text = converter.convertToTableMarkdown(); pasteText(text); - } else { + } else if (!hasPlainText(pasteEvent)) { const fileList = [...clipboardData.files]; fileList.forEach((file) => { if (file.type.indexOf('image') !== -1) { @@ -203,6 +204,11 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { } }; + hasPlainText = (data) => { + const clipboardDataList = [...data.clipboardData.items]; + return clipboardDataList.some((item) => item.type === 'text/plain'); + }; + pasteText = (text, shouldPad) => { let formattedText = text; if (shouldPad) { diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js index e855e304d27..2ae9c377683 100644 --- a/app/assets/javascripts/editor/constants.js +++ b/app/assets/javascripts/editor/constants.js @@ -42,6 +42,10 @@ export const EDITOR_EXTENSION_STORE_IS_MISSING_ERROR = s__( // EXTENSIONS' CONSTANTS // +// Source Editor Base Extension +export const EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS = 'link-anchor'; +export const EXTENSION_BASE_LINE_NUMBERS_CLASS = 'line-numbers'; + // For CI config schemas the filename must match // '*.gitlab-ci.yml' regardless of project configuration. // https://gitlab.com/gitlab-org/gitlab/-/issues/293641 diff --git a/app/assets/javascripts/editor/extensions/example_source_editor_extension.js b/app/assets/javascripts/editor/extensions/example_source_editor_extension.js index 119a2aea9eb..52e2bb0b5ff 100644 --- a/app/assets/javascripts/editor/extensions/example_source_editor_extension.js +++ b/app/assets/javascripts/editor/extensions/example_source_editor_extension.js @@ -7,6 +7,16 @@ export class MyFancyExtension { /** + * A required getter returning the extension's name + * We have to provide it for every extension instead of relying on the built-in + * `name` prop because the prop does not survive the webpack's minification + * and the name mangling. + * @returns {string} + */ + static get extensionName() { + return 'MyFancyExtension'; + } + /** * THE LIFE-CYCLE CALLBACKS */ @@ -16,11 +26,11 @@ export class MyFancyExtension { * actions, keystrokes, update options, etc. * Is called only once before the extension gets registered * - * @param { Object } [setupOptions] The setupOptions object * @param { Object } [instance] The Source Editor instance + * @param { Object } [setupOptions] The setupOptions object */ // eslint-disable-next-line class-methods-use-this,no-unused-vars - onSetup(setupOptions, instance) {} + onSetup(instance, setupOptions) {} /** * The first thing called after the extension is diff --git a/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js index 7069568275d..0290bb84b5f 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js @@ -1,32 +1,27 @@ import ciSchemaPath from '~/editor/schema/ci.json'; import { registerSchema } from '~/ide/utils'; -import { SourceEditorExtension } from './source_editor_extension_base'; -export class CiSchemaExtension extends SourceEditorExtension { - /** - * Registers a syntax schema to the editor based on project - * identifier and commit. - * - * The schema is added to the file that is currently edited - * in the editor. - * - * @param {Object} opts - * @param {String} opts.projectNamespace - * @param {String} opts.projectPath - * @param {String?} opts.ref - Current ref. Defaults to main - */ - registerCiSchema() { - // In order for workers loaded from `data://` as the - // ones loaded by monaco editor, we use absolute URLs - // to fetch schema files, hence the `gon.gitlab_url` - // reference. This prevents error: - // "Failed to execute 'fetch' on 'WorkerGlobalScope'" - const absoluteSchemaUrl = gon.gitlab_url + ciSchemaPath; - const modelFileName = this.getModel().uri.path.split('/').pop(); +export class CiSchemaExtension { + static get extensionName() { + return 'CiSchema'; + } + // eslint-disable-next-line class-methods-use-this + provides() { + return { + registerCiSchema: (instance) => { + // In order for workers loaded from `data://` as the + // ones loaded by monaco editor, we use absolute URLs + // to fetch schema files, hence the `gon.gitlab_url` + // reference. This prevents error: + // "Failed to execute 'fetch' on 'WorkerGlobalScope'" + const absoluteSchemaUrl = gon.gitlab_url + ciSchemaPath; + const modelFileName = instance.getModel().uri.path.split('/').pop(); - registerSchema({ - uri: absoluteSchemaUrl, - fileMatch: [modelFileName], - }); + registerSchema({ + uri: absoluteSchemaUrl, + fileMatch: [modelFileName], + }); + }, + }; } } diff --git a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js index 03c68fed3b1..3aa19df964c 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js +++ b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js @@ -1,13 +1,16 @@ import { Range } from 'monaco-editor'; -import { waitForCSSLoaded } from '~/helpers/startup_css_helper'; -import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION, EDITOR_TYPE_CODE } from '../constants'; +import { + EDITOR_TYPE_CODE, + EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS, + EXTENSION_BASE_LINE_NUMBERS_CLASS, +} from '../constants'; const hashRegexp = new RegExp('#?L', 'g'); const createAnchor = (href) => { const fragment = new DocumentFragment(); const el = document.createElement('a'); - el.classList.add('link-anchor'); + el.classList.add(EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS); el.href = href; fragment.appendChild(el); el.addEventListener('contextmenu', (e) => { @@ -17,38 +20,46 @@ const createAnchor = (href) => { }; export class SourceEditorExtension { - constructor({ instance, ...options } = {}) { - if (instance) { - Object.assign(instance, options); - SourceEditorExtension.highlightLines(instance); - if (instance.getEditorType && instance.getEditorType() === EDITOR_TYPE_CODE) { - SourceEditorExtension.setupLineLinking(instance); - } - SourceEditorExtension.deferRerender(instance); - } else if (Object.entries(options).length) { - throw new Error(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION); + static get extensionName() { + return 'BaseExtension'; + } + + // eslint-disable-next-line class-methods-use-this + onUse(instance) { + SourceEditorExtension.highlightLines(instance); + if (instance.getEditorType && instance.getEditorType() === EDITOR_TYPE_CODE) { + SourceEditorExtension.setupLineLinking(instance); } } - static deferRerender(instance) { - waitForCSSLoaded(() => { - instance.layout(); - }); + static onMouseMoveHandler(e) { + const target = e.target.element; + if (target.classList.contains(EXTENSION_BASE_LINE_NUMBERS_CLASS)) { + const lineNum = e.target.position.lineNumber; + const hrefAttr = `#L${lineNum}`; + let lineLink = target.querySelector('a'); + if (!lineLink) { + lineLink = createAnchor(hrefAttr); + target.appendChild(lineLink); + } + } } - static removeHighlights(instance) { - Object.assign(instance, { - lineDecorations: instance.deltaDecorations(instance.lineDecorations || [], []), + static setupLineLinking(instance) { + instance.onMouseMove(SourceEditorExtension.onMouseMoveHandler); + instance.onMouseDown((e) => { + const isCorrectAnchor = e.target.element.classList.contains( + EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS, + ); + if (!isCorrectAnchor) { + return; + } + if (instance.lineDecorations) { + instance.deltaDecorations(instance.lineDecorations, []); + } }); } - /** - * Returns a function that can only be invoked once between - * each browser screen repaint. - * @param {Object} instance - The Source Editor instance - * @param {Array} bounds - The [start, end] array with start - * and end coordinates for highlighting - */ static highlightLines(instance, bounds = null) { const [start, end] = bounds && Array.isArray(bounds) @@ -74,29 +85,29 @@ export class SourceEditorExtension { } } - static onMouseMoveHandler(e) { - const target = e.target.element; - if (target.classList.contains('line-numbers')) { - const lineNum = e.target.position.lineNumber; - const hrefAttr = `#L${lineNum}`; - let el = target.querySelector('a'); - if (!el) { - el = createAnchor(hrefAttr); - target.appendChild(el); - } - } - } + // eslint-disable-next-line class-methods-use-this + provides() { + return { + /** + * Removes existing line decorations and updates the reference on the instance + * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance + */ + removeHighlights: (instance) => { + Object.assign(instance, { + lineDecorations: instance.deltaDecorations(instance.lineDecorations || [], []), + }); + }, - static setupLineLinking(instance) { - instance.onMouseMove(SourceEditorExtension.onMouseMoveHandler); - instance.onMouseDown((e) => { - const isCorrectAnchor = e.target.element.classList.contains('link-anchor'); - if (!isCorrectAnchor) { - return; - } - if (instance.lineDecorations) { - instance.deltaDecorations(instance.lineDecorations, []); - } - }); + /** + * Returns a function that can only be invoked once between + * each browser screen repaint. + * @param {Array} bounds - The [start, end] array with start + * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance + * and end coordinates for highlighting + */ + highlightLines(instance, bounds = null) { + SourceEditorExtension.highlightLines(instance, bounds); + }, + }; } } diff --git a/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js b/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js index 397e090ed30..ba4980896e5 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js @@ -1,8 +1,16 @@ import { Position } from 'monaco-editor'; -import { SourceEditorExtension } from './source_editor_extension_base'; -export class FileTemplateExtension extends SourceEditorExtension { - navigateFileStart() { - this.setPosition(new Position(1, 1)); +export class FileTemplateExtension { + static get extensionName() { + return 'FileTemplate'; + } + + // eslint-disable-next-line class-methods-use-this + provides() { + return { + navigateFileStart: (instance) => { + instance.setPosition(new Position(1, 1)); + }, + }; } } diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js index 57de21c933e..a16fe93026e 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js @@ -1,248 +1,102 @@ -import { debounce } from 'lodash'; -import { BLOB_PREVIEW_ERROR } from '~/blob_edit/constants'; -import createFlash from '~/flash'; -import { sanitize } from '~/lib/dompurify'; -import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; -import syntaxHighlight from '~/syntax_highlight'; -import { - EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS, - EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, - EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH, - EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS, - EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY, -} from '../constants'; -import { SourceEditorExtension } from './source_editor_extension_base'; - -const getPreview = (text, previewMarkdownPath) => { - return axios - .post(previewMarkdownPath, { - text, - }) - .then(({ data }) => { - return data.body; - }); -}; - -const setupDomElement = ({ injectToEl = null } = {}) => { - const previewEl = document.createElement('div'); - previewEl.classList.add(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS); - previewEl.style.display = 'none'; - if (injectToEl) { - injectToEl.appendChild(previewEl); +export class EditorMarkdownExtension { + static get extensionName() { + return 'EditorMarkdown'; } - return previewEl; -}; -export class EditorMarkdownExtension extends SourceEditorExtension { - constructor({ instance, previewMarkdownPath, ...args } = {}) { - super({ instance, ...args }); - Object.assign(instance, { - previewMarkdownPath, - preview: { - el: undefined, - action: undefined, - shown: false, - modelChangeListener: undefined, + // eslint-disable-next-line class-methods-use-this + provides() { + return { + getSelectedText: (instance, selection = instance.getSelection()) => { + const { startLineNumber, endLineNumber, startColumn, endColumn } = selection; + const valArray = instance.getValue().split('\n'); + let text = ''; + if (startLineNumber === endLineNumber) { + text = valArray[startLineNumber - 1].slice(startColumn - 1, endColumn - 1); + } else { + const startLineText = valArray[startLineNumber - 1].slice(startColumn - 1); + const endLineText = valArray[endLineNumber - 1].slice(0, endColumn - 1); + + for (let i = startLineNumber, k = endLineNumber - 1; i < k; i += 1) { + text += `${valArray[i]}`; + if (i !== k - 1) text += `\n`; + } + text = text + ? [startLineText, text, endLineText].join('\n') + : [startLineText, endLineText].join('\n'); + } + return text; }, - }); - this.setupPreviewAction.call(instance); - - instance.getModel().onDidChangeLanguage(({ newLanguage, oldLanguage } = {}) => { - if (newLanguage === 'markdown' && oldLanguage !== newLanguage) { - instance.setupPreviewAction(); - } else { - instance.cleanup(); - } - }); - - instance.onDidChangeModel(() => { - const model = instance.getModel(); - if (model) { - const { language } = model.getLanguageIdentifier(); - instance.cleanup(); - if (language === 'markdown') { - instance.setupPreviewAction(); + replaceSelectedText: (instance, text, select) => { + const forceMoveMarkers = !select; + instance.executeEdits('', [{ range: instance.getSelection(), text, forceMoveMarkers }]); + }, + moveCursor: (instance, dx = 0, dy = 0) => { + const pos = instance.getPosition(); + pos.column += dx; + pos.lineNumber += dy; + instance.setPosition(pos); + }, + /** + * Adjust existing selection to select text within the original selection. + * - If `selectedText` is not supplied, we fetch selected text with + * + * ALGORITHM: + * + * MULTI-LINE SELECTION + * 1. Find line that contains `toSelect` text. + * 2. Using the index of this line and the position of `toSelect` text in it, + * construct: + * * newStartLineNumber + * * newStartColumn + * + * SINGLE-LINE SELECTION + * 1. Use `startLineNumber` from the current selection as `newStartLineNumber` + * 2. Find the position of `toSelect` text in it to get `newStartColumn` + * + * 3. `newEndLineNumber` — Since this method is supposed to be used with + * markdown decorators that are pretty short, the `newEndLineNumber` is + * suggested to be assumed the same as the startLine. + * 4. `newEndColumn` — pretty obvious + * 5. Adjust the start and end positions of the current selection + * 6. Re-set selection on the instance + * + * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance. Is passed automatically. + * @param {string} toSelect - New text to select within current selection. + * @param {string} selectedText - Currently selected text. It's just a + * shortcut: If it's not supplied, we fetch selected text from the instance + */ + selectWithinSelection: (instance, toSelect, selectedText) => { + const currentSelection = instance.getSelection(); + if (currentSelection.isEmpty() || !toSelect) { + return; + } + const text = selectedText || instance.getSelectedText(currentSelection); + let lineShift; + let newStartLineNumber; + let newStartColumn; + + const textLines = text.split('\n'); + + if (textLines.length > 1) { + // Multi-line selection + lineShift = textLines.findIndex((line) => line.indexOf(toSelect) !== -1); + newStartLineNumber = currentSelection.startLineNumber + lineShift; + newStartColumn = textLines[lineShift].indexOf(toSelect) + 1; + } else { + // Single-line selection + newStartLineNumber = currentSelection.startLineNumber; + newStartColumn = currentSelection.startColumn + text.indexOf(toSelect); } - } - }); - } - - static togglePreviewLayout() { - const { width, height } = this.getLayoutInfo(); - const newWidth = this.preview.shown - ? width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH - : width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH; - this.layout({ width: newWidth, height }); - } - - static togglePreviewPanel() { - const parentEl = this.getDomNode().parentElement; - const { el: previewEl } = this.preview; - parentEl.classList.toggle(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS); - - if (previewEl.style.display === 'none') { - // Show the preview panel - this.fetchPreview(); - } else { - // Hide the preview panel - previewEl.style.display = 'none'; - } - } - - cleanup() { - if (this.preview.modelChangeListener) { - this.preview.modelChangeListener.dispose(); - } - this.preview.action.dispose(); - if (this.preview.shown) { - EditorMarkdownExtension.togglePreviewPanel.call(this); - EditorMarkdownExtension.togglePreviewLayout.call(this); - } - this.preview.shown = false; - } - - fetchPreview() { - const { el: previewEl } = this.preview; - getPreview(this.getValue(), this.previewMarkdownPath) - .then((data) => { - previewEl.innerHTML = sanitize(data); - syntaxHighlight(previewEl.querySelectorAll('.js-syntax-highlight')); - previewEl.style.display = 'block'; - }) - .catch(() => createFlash(BLOB_PREVIEW_ERROR)); - } - setupPreviewAction() { - if (this.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)) return; + const newEndLineNumber = newStartLineNumber; + const newEndColumn = newStartColumn + toSelect.length; - this.preview.action = this.addAction({ - id: EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, - label: __('Preview Markdown'), - keybindings: [ - // eslint-disable-next-line no-bitwise,no-undef - monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_P), - ], - contextMenuGroupId: 'navigation', - contextMenuOrder: 1.5, + const newSelection = currentSelection + .setStartPosition(newStartLineNumber, newStartColumn) + .setEndPosition(newEndLineNumber, newEndColumn); - // Method that will be executed when the action is triggered. - // @param ed The editor instance is passed in as a convenience - run(instance) { - instance.togglePreview(); + instance.setSelection(newSelection); }, - }); - } - - togglePreview() { - if (!this.preview?.el) { - this.preview.el = setupDomElement({ injectToEl: this.getDomNode().parentElement }); - } - EditorMarkdownExtension.togglePreviewLayout.call(this); - EditorMarkdownExtension.togglePreviewPanel.call(this); - - if (!this.preview?.shown) { - this.preview.modelChangeListener = this.onDidChangeModelContent( - debounce(this.fetchPreview.bind(this), EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY), - ); - } else { - this.preview.modelChangeListener.dispose(); - } - - this.preview.shown = !this.preview?.shown; - } - - getSelectedText(selection = this.getSelection()) { - const { startLineNumber, endLineNumber, startColumn, endColumn } = selection; - const valArray = this.getValue().split('\n'); - let text = ''; - if (startLineNumber === endLineNumber) { - text = valArray[startLineNumber - 1].slice(startColumn - 1, endColumn - 1); - } else { - const startLineText = valArray[startLineNumber - 1].slice(startColumn - 1); - const endLineText = valArray[endLineNumber - 1].slice(0, endColumn - 1); - - for (let i = startLineNumber, k = endLineNumber - 1; i < k; i += 1) { - text += `${valArray[i]}`; - if (i !== k - 1) text += `\n`; - } - text = text - ? [startLineText, text, endLineText].join('\n') - : [startLineText, endLineText].join('\n'); - } - return text; - } - - replaceSelectedText(text, select = undefined) { - const forceMoveMarkers = !select; - this.executeEdits('', [{ range: this.getSelection(), text, forceMoveMarkers }]); - } - - moveCursor(dx = 0, dy = 0) { - const pos = this.getPosition(); - pos.column += dx; - pos.lineNumber += dy; - this.setPosition(pos); - } - - /** - * Adjust existing selection to select text within the original selection. - * - If `selectedText` is not supplied, we fetch selected text with - * - * ALGORITHM: - * - * MULTI-LINE SELECTION - * 1. Find line that contains `toSelect` text. - * 2. Using the index of this line and the position of `toSelect` text in it, - * construct: - * * newStartLineNumber - * * newStartColumn - * - * SINGLE-LINE SELECTION - * 1. Use `startLineNumber` from the current selection as `newStartLineNumber` - * 2. Find the position of `toSelect` text in it to get `newStartColumn` - * - * 3. `newEndLineNumber` — Since this method is supposed to be used with - * markdown decorators that are pretty short, the `newEndLineNumber` is - * suggested to be assumed the same as the startLine. - * 4. `newEndColumn` — pretty obvious - * 5. Adjust the start and end positions of the current selection - * 6. Re-set selection on the instance - * - * @param {string} toSelect - New text to select within current selection. - * @param {string} selectedText - Currently selected text. It's just a - * shortcut: If it's not supplied, we fetch selected text from the instance - */ - selectWithinSelection(toSelect, selectedText) { - const currentSelection = this.getSelection(); - if (currentSelection.isEmpty() || !toSelect) { - return; - } - const text = selectedText || this.getSelectedText(currentSelection); - let lineShift; - let newStartLineNumber; - let newStartColumn; - - const textLines = text.split('\n'); - - if (textLines.length > 1) { - // Multi-line selection - lineShift = textLines.findIndex((line) => line.indexOf(toSelect) !== -1); - newStartLineNumber = currentSelection.startLineNumber + lineShift; - newStartColumn = textLines[lineShift].indexOf(toSelect) + 1; - } else { - // Single-line selection - newStartLineNumber = currentSelection.startLineNumber; - newStartColumn = currentSelection.startColumn + text.indexOf(toSelect); - } - - const newEndLineNumber = newStartLineNumber; - const newEndColumn = newStartColumn + toSelect.length; - - const newSelection = currentSelection - .setStartPosition(newStartLineNumber, newStartColumn) - .setEndPosition(newEndLineNumber, newEndColumn); - - this.setSelection(newSelection); + }; } } diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js new file mode 100644 index 00000000000..9d53268c340 --- /dev/null +++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js @@ -0,0 +1,167 @@ +import { debounce } from 'lodash'; +import { BLOB_PREVIEW_ERROR } from '~/blob_edit/constants'; +import createFlash from '~/flash'; +import { sanitize } from '~/lib/dompurify'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import syntaxHighlight from '~/syntax_highlight'; +import { + EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS, + EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, + EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH, + EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS, + EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY, +} from '../constants'; + +const fetchPreview = (text, previewMarkdownPath) => { + return axios + .post(previewMarkdownPath, { + text, + }) + .then(({ data }) => { + return data.body; + }); +}; + +const setupDomElement = ({ injectToEl = null } = {}) => { + const previewEl = document.createElement('div'); + previewEl.classList.add(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS); + previewEl.style.display = 'none'; + if (injectToEl) { + injectToEl.appendChild(previewEl); + } + return previewEl; +}; + +export class EditorMarkdownPreviewExtension { + static get extensionName() { + return 'EditorMarkdownPreview'; + } + + onSetup(instance, setupOptions) { + this.preview = { + el: undefined, + action: undefined, + shown: false, + modelChangeListener: undefined, + path: setupOptions.previewMarkdownPath, + }; + this.setupPreviewAction(instance); + + instance.getModel().onDidChangeLanguage(({ newLanguage, oldLanguage } = {}) => { + if (newLanguage === 'markdown' && oldLanguage !== newLanguage) { + instance.setupPreviewAction(); + } else { + instance.cleanup(); + } + }); + + instance.onDidChangeModel(() => { + const model = instance.getModel(); + if (model) { + const { language } = model.getLanguageIdentifier(); + instance.cleanup(); + if (language === 'markdown') { + instance.setupPreviewAction(); + } + } + }); + } + + togglePreviewLayout(instance) { + const { width, height } = instance.getLayoutInfo(); + const newWidth = this.preview.shown + ? width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH + : width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH; + instance.layout({ width: newWidth, height }); + } + + togglePreviewPanel(instance) { + const parentEl = instance.getDomNode().parentElement; + const { el: previewEl } = this.preview; + parentEl.classList.toggle(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS); + + if (previewEl.style.display === 'none') { + // Show the preview panel + this.fetchPreview(instance); + } else { + // Hide the preview panel + previewEl.style.display = 'none'; + } + } + + fetchPreview(instance) { + const { el: previewEl } = this.preview; + fetchPreview(instance.getValue(), this.preview.path) + .then((data) => { + previewEl.innerHTML = sanitize(data); + syntaxHighlight(previewEl.querySelectorAll('.js-syntax-highlight')); + previewEl.style.display = 'block'; + }) + .catch(() => createFlash(BLOB_PREVIEW_ERROR)); + } + + setupPreviewAction(instance) { + if (instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)) return; + + this.preview.action = instance.addAction({ + id: EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, + label: __('Preview Markdown'), + keybindings: [ + // eslint-disable-next-line no-bitwise,no-undef + monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_P), + ], + contextMenuGroupId: 'navigation', + contextMenuOrder: 1.5, + + // Method that will be executed when the action is triggered. + // @param ed The editor instance is passed in as a convenience + run(inst) { + inst.togglePreview(); + }, + }); + } + + provides() { + return { + markdownPreview: this.preview, + + cleanup: (instance) => { + if (this.preview.modelChangeListener) { + this.preview.modelChangeListener.dispose(); + } + this.preview.action.dispose(); + if (this.preview.shown) { + this.togglePreviewPanel(instance); + this.togglePreviewLayout(instance); + } + this.preview.shown = false; + }, + + fetchPreview: (instance) => this.fetchPreview(instance), + + setupPreviewAction: (instance) => this.setupPreviewAction(instance), + + togglePreview: (instance) => { + if (!this.preview?.el) { + this.preview.el = setupDomElement({ injectToEl: instance.getDomNode().parentElement }); + } + this.togglePreviewLayout(instance); + this.togglePreviewPanel(instance); + + if (!this.preview?.shown) { + this.preview.modelChangeListener = instance.onDidChangeModelContent( + debounce( + this.fetchPreview.bind(this, instance), + EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY, + ), + ); + } else { + this.preview.modelChangeListener.dispose(); + } + + this.preview.shown = !this.preview?.shown; + }, + }; + } +} diff --git a/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js b/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js index 98e05489c1c..4e8c11bac54 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js @@ -1,7 +1,15 @@ +/** + * A WebIDE Extension options for Source Editor + * @typedef {Object} WebIDEExtensionOptions + * @property {Object} modelManager The root manager for WebIDE models + * @property {Object} store The state store for communication + * @property {Object} file + * @property {Object} options The Monaco editor options + */ + import { debounce } from 'lodash'; import { KeyCode, KeyMod, Range } from 'monaco-editor'; import { EDITOR_TYPE_DIFF } from '~/editor/constants'; -import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import Disposable from '~/ide/lib/common/disposable'; import { editorOptions } from '~/ide/lib/editor_options'; import keymap from '~/ide/lib/keymap.json'; @@ -11,154 +19,168 @@ const isDiffEditorType = (instance) => { }; export const UPDATE_DIMENSIONS_DELAY = 200; +const defaultOptions = { + modelManager: undefined, + store: undefined, + file: undefined, + options: {}, +}; -export class EditorWebIdeExtension extends SourceEditorExtension { - constructor({ instance, modelManager, ...options } = {}) { - super({ - instance, - ...options, - modelManager, - disposable: new Disposable(), - debouncedUpdate: debounce(() => { - instance.updateDimensions(); - }, UPDATE_DIMENSIONS_DELAY), - }); - - window.addEventListener('resize', instance.debouncedUpdate, false); - - instance.onDidDispose(() => { - window.removeEventListener('resize', instance.debouncedUpdate); - - // catch any potential errors with disposing the error - // this is mainly for tests caused by elements not existing - try { - instance.disposable.dispose(); - } catch (e) { - if (process.env.NODE_ENV !== 'test') { - // eslint-disable-next-line no-console - console.error(e); - } - } - }); +const addActions = (instance, store) => { + const getKeyCode = (key) => { + const monacoKeyMod = key.indexOf('KEY_') === 0; - EditorWebIdeExtension.addActions(instance); - } + return monacoKeyMod ? KeyCode[key] : KeyMod[key]; + }; - static addActions(instance) { - const { store } = instance; - const getKeyCode = (key) => { - const monacoKeyMod = key.indexOf('KEY_') === 0; + keymap.forEach((command) => { + const { bindings, id, label, action } = command; - return monacoKeyMod ? KeyCode[key] : KeyMod[key]; - }; + const keybindings = bindings.map((binding) => { + const keys = binding.split('+'); - keymap.forEach((command) => { - const { bindings, id, label, action } = command; - - const keybindings = bindings.map((binding) => { - const keys = binding.split('+'); - - // eslint-disable-next-line no-bitwise - return keys.length > 1 ? getKeyCode(keys[0]) | getKeyCode(keys[1]) : getKeyCode(keys[0]); - }); - - instance.addAction({ - id, - label, - keybindings, - run() { - store.dispatch(action.name, action.params); - return null; - }, - }); + // eslint-disable-next-line no-bitwise + return keys.length > 1 ? getKeyCode(keys[0]) | getKeyCode(keys[1]) : getKeyCode(keys[0]); }); - } - - createModel(file, head = null) { - return this.modelManager.addModel(file, head); - } - - attachModel(model) { - if (isDiffEditorType(this)) { - this.setModel({ - original: model.getOriginalModel(), - modified: model.getModel(), - }); - return; - } - - this.setModel(model.getModel()); + instance.addAction({ + id, + label, + keybindings, + run() { + store.dispatch(action.name, action.params); + return null; + }, + }); + }); +}; - this.updateOptions( - editorOptions.reduce((acc, obj) => { - Object.keys(obj).forEach((key) => { - Object.assign(acc, { - [key]: obj[key](model), - }); - }); - return acc; - }, {}), - ); - } +const renderSideBySide = (domElement) => { + return domElement.offsetWidth >= 700; +}; - attachMergeRequestModel(model) { - this.setModel({ - original: model.getBaseModel(), - modified: model.getModel(), +const updateInstanceDimensions = (instance) => { + instance.layout(); + if (isDiffEditorType(instance)) { + instance.updateOptions({ + renderSideBySide: renderSideBySide(instance.getDomNode()), }); } +}; - updateDimensions() { - this.layout(); - this.updateDiffView(); +export class EditorWebIdeExtension { + static get extensionName() { + return 'EditorWebIde'; } - setPos({ lineNumber, column }) { - this.revealPositionInCenter({ - lineNumber, - column, - }); - this.setPosition({ - lineNumber, - column, - }); + /** + * Set up the WebIDE extension for Source Editor + * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance + * @param {WebIDEExtensionOptions} setupOptions + */ + onSetup(instance, setupOptions = defaultOptions) { + this.modelManager = setupOptions.modelManager; + this.store = setupOptions.store; + this.file = setupOptions.file; + this.options = setupOptions.options; + + this.disposable = new Disposable(); + this.debouncedUpdate = debounce(() => { + updateInstanceDimensions(instance); + }, UPDATE_DIMENSIONS_DELAY); + + addActions(instance, setupOptions.store); } - onPositionChange(cb) { - if (!this.onDidChangeCursorPosition) { - return; - } + onUse(instance) { + window.addEventListener('resize', this.debouncedUpdate, false); - this.disposable.add(this.onDidChangeCursorPosition((e) => cb(this, e))); + instance.onDidDispose(() => { + this.onUnuse(); + }); } - updateDiffView() { - if (!isDiffEditorType(this)) { - return; + onUnuse() { + window.removeEventListener('resize', this.debouncedUpdate); + + // catch any potential errors with disposing the error + // this is mainly for tests caused by elements not existing + try { + this.disposable.dispose(); + } catch (e) { + if (process.env.NODE_ENV !== 'test') { + // eslint-disable-next-line no-console + console.error(e); + } } - - this.updateOptions({ - renderSideBySide: EditorWebIdeExtension.renderSideBySide(this.getDomNode()), - }); } - replaceSelectedText(text) { - let selection = this.getSelection(); - const range = new Range( - selection.startLineNumber, - selection.startColumn, - selection.endLineNumber, - selection.endColumn, - ); + provides() { + return { + createModel: (instance, file, head = null) => { + return this.modelManager.addModel(file, head); + }, + attachModel: (instance, model) => { + if (isDiffEditorType(instance)) { + instance.setModel({ + original: model.getOriginalModel(), + modified: model.getModel(), + }); - this.executeEdits('', [{ range, text }]); + return; + } - selection = this.getSelection(); - this.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn }); - } + instance.setModel(model.getModel()); + + instance.updateOptions( + editorOptions.reduce((acc, obj) => { + Object.keys(obj).forEach((key) => { + Object.assign(acc, { + [key]: obj[key](model), + }); + }); + return acc; + }, {}), + ); + }, + attachMergeRequestModel: (instance, model) => { + instance.setModel({ + original: model.getBaseModel(), + modified: model.getModel(), + }); + }, + updateDimensions: (instance) => updateInstanceDimensions(instance), + setPos: (instance, { lineNumber, column }) => { + instance.revealPositionInCenter({ + lineNumber, + column, + }); + instance.setPosition({ + lineNumber, + column, + }); + }, + onPositionChange: (instance, cb) => { + if (typeof instance.onDidChangeCursorPosition !== 'function') { + return; + } - static renderSideBySide(domElement) { - return domElement.offsetWidth >= 700; + this.disposable.add(instance.onDidChangeCursorPosition((e) => cb(instance, e))); + }, + replaceSelectedText: (instance, text) => { + let selection = instance.getSelection(); + const range = new Range( + selection.startLineNumber, + selection.startColumn, + selection.endLineNumber, + selection.endColumn, + ); + + instance.executeEdits('', [{ range, text }]); + + selection = instance.getSelection(); + instance.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn }); + }, + }; } } diff --git a/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js b/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js index 212e09c8724..05ce617ca7c 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js @@ -1,50 +1,46 @@ +/** + * A Yaml Editor Extension options for Source Editor + * @typedef {Object} YamlEditorExtensionOptions + * @property { boolean } enableComments Convert model nodes with the comment + * pattern to comments? + * @property { string } highlightPath Add a line highlight to the + * node specified by this e.g. `"foo.bar[0]"` + * @property { * } model Any JS Object that will be stringified and used as the + * editor's value. Equivalent to using `setDataModel()` + * @property options SourceEditorExtension Options + */ + import { toPath } from 'lodash'; import { parseDocument, Document, visit, isScalar, isCollection, isMap } from 'yaml'; import { findPair } from 'yaml/util'; -import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; -export class YamlEditorExtension extends SourceEditorExtension { +export class YamlEditorExtension { + static get extensionName() { + return 'YamlEditor'; + } + /** * Extends the source editor with capabilities for yaml files. * - * @param { Instance } instance Source Editor Instance - * @param { boolean } enableComments Convert model nodes with the comment - * pattern to comments? - * @param { string } highlightPath Add a line highlight to the - * node specified by this e.g. `"foo.bar[0]"` - * @param { * } model Any JS Object that will be stringified and used as the - * editor's value. Equivalent to using `setDataModel()` - * @param options SourceEditorExtension Options + * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance + * @param {YamlEditorExtensionOptions} setupOptions */ - constructor({ - instance, - enableComments = false, - highlightPath = null, - model = null, - ...options - } = {}) { - super({ - instance, - options: { - ...options, - enableComments, - highlightPath, - }, - }); + onSetup(instance, setupOptions = {}) { + const { enableComments = false, highlightPath = null, model = null } = setupOptions; + this.enableComments = enableComments; + this.highlightPath = highlightPath; + this.model = model; if (model) { - YamlEditorExtension.initFromModel(instance, model); + this.initFromModel(instance, model); } instance.onDidChangeModelContent(() => instance.onUpdate()); } - /** - * @private - */ - static initFromModel(instance, model) { + initFromModel(instance, model) { const doc = new Document(model); - if (instance.options.enableComments) { + if (this.enableComments) { YamlEditorExtension.transformComments(doc); } instance.setValue(doc.toString()); @@ -160,110 +156,13 @@ export class YamlEditorExtension extends SourceEditorExtension { return doc; } - /** - * Get the editor's value parsed as a `Document` as defined by the `yaml` - * package - * @returns {Document} - */ - getDoc() { - return parseDocument(this.getValue()); - } - - /** - * Accepts a `Document` as defined by the `yaml` package and - * sets the Editor's value to a stringified version of it. - * @param { Document } doc - */ - setDoc(doc) { - if (this.options.enableComments) { - YamlEditorExtension.transformComments(doc); - } - - if (!this.getValue()) { - this.setValue(doc.toString()); - } else { - this.updateValue(doc.toString()); - } - } - - /** - * Returns the parsed value of the Editor's content as JS. - * @returns {*} - */ - getDataModel() { - return this.getDoc().toJS(); - } - - /** - * Accepts any JS Object and sets the Editor's value to a stringified version - * of that value. - * - * @param value - */ - setDataModel(value) { - this.setDoc(new Document(value)); - } - - /** - * Method to be executed when the Editor's <TextModel> was updated - */ - onUpdate() { - if (this.options.highlightPath) { - this.highlight(this.options.highlightPath); - } - } - - /** - * Set the editors content to the input without recreating the content model. - * - * @param blob - */ - updateValue(blob) { - // Using applyEdits() instead of setValue() ensures that tokens such as - // highlighted lines aren't deleted/recreated which causes a flicker. - const model = this.getModel(); - model.applyEdits([ - { - // A nice improvement would be to replace getFullModelRange() with - // a range of the actual diff, avoiding re-formatting the document, - // but that's something for a later iteration. - range: model.getFullModelRange(), - text: blob, - }, - ]); - } - - /** - * Add a line highlight style to the node specified by the path. - * - * @param {string|null|false} path A path to a node of the Editor's value, - * e.g. `"foo.bar[0]"`. If the value is falsy, this will remove all - * highlights. - */ - highlight(path) { - if (this.options.highlightPath === path) return; - if (!path) { - SourceEditorExtension.removeHighlights(this); - } else { - const res = this.locate(path); - SourceEditorExtension.highlightLines(this, res); - } - this.options.highlightPath = path || null; + static getDoc(instance) { + return parseDocument(instance.getValue()); } - /** - * Return the line numbers of a certain node identified by `path` within - * the yaml. - * - * @param {string} path A path to a node, eg. `foo.bar[0]` - * @returns {number[]} Array following the schema `[firstLine, lastLine]` - * (both inclusive) - * - * @throws {Error} Will throw if the path is not found inside the document - */ - locate(path) { + static locate(instance, path) { if (!path) throw Error(`No path provided.`); - const blob = this.getValue(); + const blob = instance.getValue(); const doc = parseDocument(blob); const pathArray = toPath(path); @@ -290,4 +189,120 @@ export class YamlEditorExtension extends SourceEditorExtension { const endLine = (endSlice.match(/\n/g) || []).length; return [startLine, endLine]; } + + setDoc(instance, doc) { + if (this.enableComments) { + YamlEditorExtension.transformComments(doc); + } + + if (!instance.getValue()) { + instance.setValue(doc.toString()); + } else { + instance.updateValue(doc.toString()); + } + } + + highlight(instance, path) { + // IMPORTANT + // removeHighlight and highlightLines both come from + // SourceEditorExtension. So it has to be installed prior to this extension + if (this.highlightPath === path) return; + if (!path) { + instance.removeHighlights(); + } else { + const res = YamlEditorExtension.locate(instance, path); + instance.highlightLines(res); + } + this.highlightPath = path || null; + } + + provides() { + return { + /** + * Get the editor's value parsed as a `Document` as defined by the `yaml` + * package + * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance + * @returns {Document} + */ + getDoc: (instance) => YamlEditorExtension.getDoc(instance), + + /** + * Accepts a `Document` as defined by the `yaml` package and + * sets the Editor's value to a stringified version of it. + * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance + * @param { Document } doc + */ + setDoc: (instance, doc) => this.setDoc(instance, doc), + + /** + * Returns the parsed value of the Editor's content as JS. + * @returns {*} + */ + getDataModel: (instance) => YamlEditorExtension.getDoc(instance).toJS(), + + /** + * Accepts any JS Object and sets the Editor's value to a stringified version + * of that value. + * + * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance + * @param value + */ + setDataModel: (instance, value) => this.setDoc(instance, new Document(value)), + + /** + * Method to be executed when the Editor's <TextModel> was updated + */ + onUpdate: (instance) => { + if (this.highlightPath) { + this.highlight(instance, this.highlightPath); + } + }, + + /** + * Set the editors content to the input without recreating the content model. + * + * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance + * @param blob + */ + updateValue: (instance, blob) => { + // Using applyEdits() instead of setValue() ensures that tokens such as + // highlighted lines aren't deleted/recreated which causes a flicker. + const model = instance.getModel(); + model.applyEdits([ + { + // A nice improvement would be to replace getFullModelRange() with + // a range of the actual diff, avoiding re-formatting the document, + // but that's something for a later iteration. + range: model.getFullModelRange(), + text: blob, + }, + ]); + }, + + /** + * Add a line highlight style to the node specified by the path. + * + * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance + * @param {string|null|false} path A path to a node of the Editor's value, + * e.g. `"foo.bar[0]"`. If the value is falsy, this will remove all + * highlights. + */ + highlight: (instance, path) => this.highlight(instance, path), + + /** + * Return the line numbers of a certain node identified by `path` within + * the yaml. + * + * @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance + * @param {string} path A path to a node, eg. `foo.bar[0]` + * @returns {number[]} Array following the schema `[firstLine, lastLine]` + * (both inclusive) + * + * @throws {Error} Will throw if the path is not found inside the document + */ + locate: (instance, path) => YamlEditorExtension.locate(instance, path), + + initFromModel: (instance, model) => this.initFromModel(instance, model), + }; + } } diff --git a/app/assets/javascripts/editor/source_editor.js b/app/assets/javascripts/editor/source_editor.js index 81ddf8d77fa..57e2b0da565 100644 --- a/app/assets/javascripts/editor/source_editor.js +++ b/app/assets/javascripts/editor/source_editor.js @@ -1,4 +1,5 @@ import { editor as monacoEditor, Uri } from 'monaco-editor'; +import { waitForCSSLoaded } from '~/helpers/startup_css_helper'; import { defaultEditorOptions } from '~/ide/lib/editor_options'; import languages from '~/ide/lib/languages'; import { registerLanguages } from '~/ide/utils'; @@ -11,10 +12,39 @@ import { EDITOR_TYPE_DIFF, } from './constants'; import { clearDomElement, setupEditorTheme, getBlobLanguage } from './utils'; +import EditorInstance from './source_editor_instance'; + +const instanceRemoveFromRegistry = (editor, instance) => { + const index = editor.instances.findIndex((inst) => inst === instance); + editor.instances.splice(index, 1); +}; + +const instanceDisposeModels = (editor, instance, model) => { + const instanceModel = instance.getModel() || model; + if (!instanceModel) { + return; + } + if (instance.getEditorType() === EDITOR_TYPE_DIFF) { + const { original, modified } = instanceModel; + if (original) { + original.dispose(); + } + if (modified) { + modified.dispose(); + } + } else { + instanceModel.dispose(); + } +}; export default class SourceEditor { + /** + * Constructs a global editor. + * @param {Object} options - Monaco config options used to create the editor + */ constructor(options = {}) { this.instances = []; + this.extensionsStore = new Map(); this.options = { extraEditorClassName: 'gl-source-editor', ...defaultEditorOptions, @@ -26,39 +56,6 @@ export default class SourceEditor { registerLanguages(...languages); } - static pushToImportsArray(arr, toImport) { - arr.push(import(toImport)); - } - - static loadExtensions(extensions) { - if (!extensions) { - return Promise.resolve(); - } - const promises = []; - const extensionsArray = typeof extensions === 'string' ? extensions.split(',') : extensions; - - extensionsArray.forEach((ext) => { - const prefix = ext.includes('/') ? '' : 'editor/'; - const trimmedExt = ext.replace(/^\//, '').trim(); - SourceEditor.pushToImportsArray(promises, `~/${prefix}${trimmedExt}`); - }); - - return Promise.all(promises); - } - - static mixIntoInstance(source, inst) { - if (!inst) { - return; - } - const isClassInstance = source.constructor.prototype !== Object.prototype; - const sanitizedSource = isClassInstance ? source.constructor.prototype : source; - Object.getOwnPropertyNames(sanitizedSource).forEach((prop) => { - if (prop !== 'constructor') { - Object.assign(inst, { [prop]: source[prop] }); - } - }); - } - static prepareInstance(el) { if (!el) { throw new Error(SOURCE_EDITOR_INSTANCE_ERROR_NO_EL); @@ -71,23 +68,6 @@ export default class SourceEditor { }); } - static manageDefaultExtensions(instance, el, extensions) { - SourceEditor.loadExtensions(extensions, instance) - .then((modules) => { - if (modules) { - modules.forEach((module) => { - instance.use(module.default); - }); - } - }) - .then(() => { - el.dispatchEvent(new Event(EDITOR_READY_EVENT)); - }) - .catch((e) => { - throw e; - }); - } - static createEditorModel({ blobPath, blobContent, @@ -115,71 +95,17 @@ export default class SourceEditor { return diffModel; } - static convertMonacoToELInstance = (inst) => { - const sourceEditorInstanceAPI = { - updateModelLanguage: (path) => { - return SourceEditor.instanceUpdateLanguage(inst, path); - }, - use: (exts = []) => { - return SourceEditor.instanceApplyExtension(inst, exts); - }, - }; - const handler = { - get(target, prop, receiver) { - if (Reflect.has(sourceEditorInstanceAPI, prop)) { - return sourceEditorInstanceAPI[prop]; - } - return Reflect.get(target, prop, receiver); - }, - }; - return new Proxy(inst, handler); - }; - - static instanceUpdateLanguage(inst, path) { - const lang = getBlobLanguage(path); - const model = inst.getModel(); - return monacoEditor.setModelLanguage(model, lang); - } - - static instanceApplyExtension(inst, exts = []) { - const extensions = [].concat(exts); - extensions.forEach((extension) => { - SourceEditor.mixIntoInstance(extension, inst); - }); - return inst; - } - - static instanceRemoveFromRegistry(editor, instance) { - const index = editor.instances.findIndex((inst) => inst === instance); - editor.instances.splice(index, 1); - } - - static instanceDisposeModels(editor, instance, model) { - const instanceModel = instance.getModel() || model; - if (!instanceModel) { - return; - } - if (instance.getEditorType() === EDITOR_TYPE_DIFF) { - const { original, modified } = instanceModel; - if (original) { - original.dispose(); - } - if (modified) { - modified.dispose(); - } - } else { - instanceModel.dispose(); - } - } - /** - * Creates a monaco instance with the given options. - * - * @param {Object} options Options used to initialize monaco. - * @param {Element} options.el The element which will be used to create the monacoEditor. + * Creates a Source Editor Instance with the given options. + * @param {Object} options Options used to initialize the instance. + * @param {Element} options.el The element to attach the instance for. * @param {string} options.blobPath The path used as the URI of the model. Monaco uses the extension of this path to determine the language. * @param {string} options.blobContent The content to initialize the monacoEditor. + * @param {string} options.blobOriginalContent The original blob's content. Is used when creating a Diff Instance. * @param {string} options.blobGlobalId This is used to help globally identify monaco instances that are created with the same blobPath. + * @param {Boolean} options.isDiff Flag to enable creation of a Diff Instance? + * @param {...*} options.instanceOptions Configuration options used to instantiate an instance. + * @returns {EditorInstance} */ createInstance({ el = undefined, @@ -187,20 +113,24 @@ export default class SourceEditor { blobContent = '', blobOriginalContent = '', blobGlobalId = uuids()[0], - extensions = [], isDiff = false, ...instanceOptions } = {}) { SourceEditor.prepareInstance(el); const createEditorFn = isDiff ? 'createDiffEditor' : 'create'; - const instance = SourceEditor.convertMonacoToELInstance( + const instance = new EditorInstance( monacoEditor[createEditorFn].call(this, el, { ...this.options, ...instanceOptions, }), + this.extensionsStore, ); + waitForCSSLoaded(() => { + instance.layout(); + }); + let model; if (instanceOptions.model !== null) { model = SourceEditor.createEditorModel({ @@ -214,16 +144,20 @@ export default class SourceEditor { } instance.onDidDispose(() => { - SourceEditor.instanceRemoveFromRegistry(this, instance); - SourceEditor.instanceDisposeModels(this, instance, model); + instanceRemoveFromRegistry(this, instance); + instanceDisposeModels(this, instance, model); }); - SourceEditor.manageDefaultExtensions(instance, el, extensions); - this.instances.push(instance); + el.dispatchEvent(new CustomEvent(EDITOR_READY_EVENT, { instance })); return instance; } + /** + * Create a Diff Instance + * @param {Object} args Options to be passed further down to createInstance() with the same signature + * @returns {EditorInstance} + */ createDiffInstance(args) { return this.createInstance({ ...args, @@ -231,14 +165,11 @@ export default class SourceEditor { }); } + /** + * Dispose global editor + * Automatically disposes all the instances registered for this editor + */ dispose() { this.instances.forEach((instance) => instance.dispose()); } - - use(exts) { - this.instances.forEach((inst) => { - inst.use(exts); - }); - return this; - } } diff --git a/app/assets/javascripts/editor/source_editor_extension.js b/app/assets/javascripts/editor/source_editor_extension.js index f6bc62a1c09..6d47e1e2248 100644 --- a/app/assets/javascripts/editor/source_editor_extension.js +++ b/app/assets/javascripts/editor/source_editor_extension.js @@ -5,10 +5,10 @@ export default class EditorExtension { if (typeof definition !== 'function') { throw new Error(EDITOR_EXTENSION_DEFINITION_ERROR); } - this.name = definition.name; // both class- and fn-based extensions have a name this.setupOptions = setupOptions; // eslint-disable-next-line new-cap this.obj = new definition(); + this.extensionName = definition.extensionName || this.obj.extensionName; // both class- and fn-based extensions have a name } get api() { diff --git a/app/assets/javascripts/editor/source_editor_instance.js b/app/assets/javascripts/editor/source_editor_instance.js index e0ca4ea518b..8372a59964b 100644 --- a/app/assets/javascripts/editor/source_editor_instance.js +++ b/app/assets/javascripts/editor/source_editor_instance.js @@ -13,7 +13,7 @@ * A Source Editor Extension * @typedef {Object} SourceEditorExtension * @property {Object} obj - * @property {string} name + * @property {string} extensionName * @property {Object} api */ @@ -43,12 +43,12 @@ const utils = { } }, - getStoredExtension: (extensionsStore, name) => { + getStoredExtension: (extensionsStore, extensionName) => { if (!extensionsStore) { logError(EDITOR_EXTENSION_STORE_IS_MISSING_ERROR); return undefined; } - return extensionsStore.get(name); + return extensionsStore.get(extensionName); }, }; @@ -73,32 +73,18 @@ export default class EditorInstance { if (methodExtension) { const extension = extensionsStore.get(methodExtension); - return (...args) => { - return extension.api[prop].call(seInstance, ...args, receiver); - }; + if (typeof extension.api[prop] === 'function') { + return extension.api[prop].bind(extension.obj, receiver); + } + + return extension.api[prop]; } return Reflect.get(seInstance[prop] ? seInstance : target, prop, receiver); }, - set(target, prop, value) { - Object.assign(seInstance, { - [prop]: value, - }); - return true; - }, }; const instProxy = new Proxy(rootInstance, getHandler); - /** - * Main entry point to apply an extension to the instance - * @param {SourceEditorExtensionDefinition} - */ - this.use = EditorInstance.useUnuse.bind(instProxy, extensionsStore, this.useExtension); - - /** - * Main entry point to un-use an extension and remove it from the instance - * @param {SourceEditorExtension} - */ - this.unuse = EditorInstance.useUnuse.bind(instProxy, extensionsStore, this.unuseExtension); + this.dispatchExtAction = EditorInstance.useUnuse.bind(instProxy, extensionsStore); return instProxy; } @@ -143,7 +129,7 @@ export default class EditorInstance { } // Existing Extension Path - const existingExt = utils.getStoredExtension(extensionsStore, definition.name); + const existingExt = utils.getStoredExtension(extensionsStore, definition.extensionName); if (existingExt) { if (isEqual(extension.setupOptions, existingExt.setupOptions)) { return existingExt; @@ -155,7 +141,7 @@ export default class EditorInstance { const extensionInstance = new EditorExtension(extension); const { setupOptions, obj: extensionObj } = extensionInstance; if (extensionObj.onSetup) { - extensionObj.onSetup(setupOptions, this); + extensionObj.onSetup(this, setupOptions); } if (extensionsStore) { this.registerExtension(extensionInstance, extensionsStore); @@ -170,14 +156,14 @@ export default class EditorInstance { * @param {Map} extensionsStore - The global registry for the extension instances */ registerExtension(extension, extensionsStore) { - const { name } = extension; + const { extensionName } = extension; const hasExtensionRegistered = - extensionsStore.has(name) && - isEqual(extension.setupOptions, extensionsStore.get(name).setupOptions); + extensionsStore.has(extensionName) && + isEqual(extension.setupOptions, extensionsStore.get(extensionName).setupOptions); if (hasExtensionRegistered) { return; } - extensionsStore.set(name, extension); + extensionsStore.set(extensionName, extension); const { obj: extensionObj } = extension; if (extensionObj.onUse) { extensionObj.onUse(this); @@ -189,7 +175,7 @@ export default class EditorInstance { * @param {SourceEditorExtension} extension - Instance of Source Editor extension */ registerExtensionMethods(extension) { - const { api, name } = extension; + const { api, extensionName } = extension; if (!api) { return; @@ -199,7 +185,7 @@ export default class EditorInstance { if (this[prop]) { logError(sprintf(EDITOR_EXTENSION_NAMING_CONFLICT_ERROR, { prop })); } else { - this.methods[prop] = name; + this.methods[prop] = extensionName; } }, this); } @@ -217,10 +203,10 @@ export default class EditorInstance { if (!extension) { throw new Error(EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR); } - const { name } = extension; - const existingExt = utils.getStoredExtension(extensionsStore, name); + const { extensionName } = extension; + const existingExt = utils.getStoredExtension(extensionsStore, extensionName); if (!existingExt) { - throw new Error(sprintf(EDITOR_EXTENSION_NOT_REGISTERED_ERROR, { name })); + throw new Error(sprintf(EDITOR_EXTENSION_NOT_REGISTERED_ERROR, { extensionName })); } const { obj: extensionObj } = existingExt; if (extensionObj.onBeforeUnuse) { @@ -237,12 +223,12 @@ export default class EditorInstance { * @param {SourceEditorExtension} extension - Instance of Source Editor extension to un-use */ unregisterExtensionMethods(extension) { - const { api, name } = extension; + const { api, extensionName } = extension; if (!api) { return; } Object.keys(api).forEach((method) => { - utils.removeExtFromMethod(method, name, this.methods); + utils.removeExtFromMethod(method, extensionName, this.methods); }); } @@ -262,6 +248,24 @@ export default class EditorInstance { } /** + * Main entry point to apply an extension to the instance + * @param {SourceEditorExtensionDefinition[]|SourceEditorExtensionDefinition} extDefs - The extension(s) to use + * @returns {EditorExtension|*} + */ + use(extDefs) { + return this.dispatchExtAction(this.useExtension, extDefs); + } + + /** + * Main entry point to remove an extension to the instance + * @param {SourceEditorExtension[]|SourceEditorExtension} exts - + * @returns {*} + */ + unuse(exts) { + return this.dispatchExtAction(this.unuseExtension, exts); + } + + /** * Get the methods returned by extensions. * @returns {Array} */ diff --git a/app/assets/javascripts/emoji/constants.js b/app/assets/javascripts/emoji/constants.js index e9f2272e759..a6eb4256561 100644 --- a/app/assets/javascripts/emoji/constants.js +++ b/app/assets/javascripts/emoji/constants.js @@ -16,3 +16,6 @@ export const CATEGORY_ICON_MAP = { export const EMOJIS_PER_ROW = 9; export const EMOJI_ROW_HEIGHT = 34; export const CATEGORY_ROW_HEIGHT = 37; + +export const CACHE_VERSION_KEY = 'gl-emoji-map-version'; +export const CACHE_KEY = 'gl-emoji-map'; diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js index 478e3f6aed9..b507792cc91 100644 --- a/app/assets/javascripts/emoji/index.js +++ b/app/assets/javascripts/emoji/index.js @@ -1,26 +1,31 @@ import { escape, minBy } from 'lodash'; +import emojiRegexFactory from 'emoji-regex'; import emojiAliases from 'emojis/aliases.json'; -import { sanitize } from '~/lib/dompurify'; import AccessorUtilities from '../lib/utils/accessor'; import axios from '../lib/utils/axios_utils'; -import { CATEGORY_ICON_MAP, FREQUENTLY_USED_KEY } from './constants'; +import { CACHE_KEY, CACHE_VERSION_KEY, CATEGORY_ICON_MAP, FREQUENTLY_USED_KEY } from './constants'; let emojiMap = null; let validEmojiNames = null; export const FALLBACK_EMOJI_KEY = 'grey_question'; // Keep the version in sync with `lib/gitlab/emoji.rb` -export const EMOJI_VERSION = '1'; +export const EMOJI_VERSION = '2'; const isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage(); async function loadEmoji() { if ( isLocalStorageAvailable && - window.localStorage.getItem('gl-emoji-map-version') === EMOJI_VERSION && - window.localStorage.getItem('gl-emoji-map') + window.localStorage.getItem(CACHE_VERSION_KEY) === EMOJI_VERSION && + window.localStorage.getItem(CACHE_KEY) ) { - return JSON.parse(window.localStorage.getItem('gl-emoji-map')); + const emojis = JSON.parse(window.localStorage.getItem(CACHE_KEY)); + // Workaround because the pride flag is broken in EMOJI_VERSION = '1' + if (emojis.gay_pride_flag) { + emojis.gay_pride_flag.e = '🏳️🌈'; + } + return emojis; } // We load the JSON file direct from the server @@ -29,15 +34,19 @@ async function loadEmoji() { const { data } = await axios.get( `${gon.relative_url_root || ''}/-/emojis/${EMOJI_VERSION}/emojis.json`, ); - window.localStorage.setItem('gl-emoji-map-version', EMOJI_VERSION); - window.localStorage.setItem('gl-emoji-map', JSON.stringify(data)); + window.localStorage.setItem(CACHE_VERSION_KEY, EMOJI_VERSION); + window.localStorage.setItem(CACHE_KEY, JSON.stringify(data)); return data; } async function loadEmojiWithNames() { - return Object.entries(await loadEmoji()).reduce((acc, [key, value]) => { - acc[key] = { ...value, name: key, e: sanitize(value.e) }; + const emojiRegex = emojiRegexFactory(); + return Object.entries(await loadEmoji()).reduce((acc, [key, value]) => { + // Filter out entries which aren't emojis + if (value.e.match(emojiRegex)?.[0] === value.e) { + acc[key] = { ...value, name: key }; + } return acc; }, {}); } diff --git a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue index 4783b92942c..0e556f093e2 100644 --- a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue +++ b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue @@ -7,6 +7,7 @@ import { escape } from 'lodash'; import csrf from '~/lib/utils/csrf'; import { __, s__, sprintf } from '~/locale'; +import rollbackEnvironment from '../graphql/mutations/rollback_environment.mutation.graphql'; import eventHub from '../event_hub'; export default { @@ -40,10 +41,15 @@ export default { required: false, default: null, }, + graphql: { + type: Boolean, + required: false, + default: false, + }, }, computed: { modalTitle() { - const title = this.environment.isLastDeployment + const title = this.isLastDeployment ? s__('Environments|Re-deploy environment %{name}?') : s__('Environments|Rollback environment %{name}?'); @@ -53,6 +59,11 @@ export default { }, commitShortSha() { if (this.hasMultipleCommits) { + if (this.graphql) { + const { lastDeployment } = this.environment; + return this.commitData(lastDeployment, 'shortId'); + } + const { last_deployment } = this.environment; return this.commitData(last_deployment, 'short_id'); } @@ -61,6 +72,11 @@ export default { }, commitUrl() { if (this.hasMultipleCommits) { + if (this.graphql) { + const { lastDeployment } = this.environment; + return this.commitData(lastDeployment, 'commitPath'); + } + const { last_deployment } = this.environment; return this.commitData(last_deployment, 'commit_path'); } @@ -68,9 +84,7 @@ export default { return this.environment.commitUrl; }, modalActionText() { - return this.environment.isLastDeployment - ? s__('Environments|Re-deploy') - : s__('Environments|Rollback'); + return this.isLastDeployment ? s__('Environments|Re-deploy') : s__('Environments|Rollback'); }, primaryProps() { let attributes = [{ variant: 'danger' }]; @@ -84,20 +98,27 @@ export default { attributes, }; }, + isLastDeployment() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return this.environment?.isLastDeployment || this.environment?.lastDeployment?.['last?']; + }, }, methods: { handleChange(event) { this.$emit('change', event); }, onOk() { - eventHub.$emit('rollbackEnvironment', this.environment); + if (this.graphql) { + this.$apollo.mutate({ + mutation: rollbackEnvironment, + variables: { environment: this.environment }, + }); + } else { + eventHub.$emit('rollbackEnvironment', this.environment); + } }, commitData(lastDeployment, key) { - if (lastDeployment && lastDeployment.commit) { - return lastDeployment.commit[key]; - } - - return ''; + return lastDeployment?.commit?.[key] ?? ''; }, }, csrf, diff --git a/app/assets/javascripts/environments/components/delete_environment_modal.vue b/app/assets/javascripts/environments/components/delete_environment_modal.vue index 26ec882472b..d3d4c7d23d8 100644 --- a/app/assets/javascripts/environments/components/delete_environment_modal.vue +++ b/app/assets/javascripts/environments/components/delete_environment_modal.vue @@ -1,7 +1,9 @@ <script> import { GlTooltipDirective, GlModal } from '@gitlab/ui'; +import createFlash from '~/flash'; import { __, s__, sprintf } from '~/locale'; import eventHub from '../event_hub'; +import deleteEnvironmentMutation from '../graphql/mutations/delete_environment.mutation.graphql'; export default { id: 'delete-environment-modal', @@ -17,6 +19,11 @@ export default { type: Object, required: true, }, + graphql: { + type: Boolean, + required: false, + default: false, + }, }, computed: { primaryProps() { @@ -49,7 +56,29 @@ export default { }, methods: { onSubmit() { - eventHub.$emit('deleteEnvironment', this.environment); + if (this.graphql) { + this.$apollo + .mutate({ + mutation: deleteEnvironmentMutation, + variables: { environment: this.environment }, + }) + .then(([message]) => { + if (message) { + createFlash({ message }); + } + }) + .catch((error) => + createFlash({ + message: s__( + 'Environments|An error occurred while deleting the environment. Check if the environment stopped; if not, stop it and try again.', + ), + error, + captureError: true, + }), + ); + } else { + eventHub.$emit('deleteEnvironment', this.environment); + } }, }, }; diff --git a/app/assets/javascripts/environments/components/enable_review_app_modal.vue b/app/assets/javascripts/environments/components/enable_review_app_modal.vue index d770a2302e8..b757c55bfdb 100644 --- a/app/assets/javascripts/environments/components/enable_review_app_modal.vue +++ b/app/assets/javascripts/environments/components/enable_review_app_modal.vue @@ -12,11 +12,20 @@ export default { ModalCopyButton, }, inject: ['defaultBranchName'], + model: { + prop: 'visible', + event: 'change', + }, props: { modalId: { type: String, required: true, }, + visible: { + type: Boolean, + required: false, + default: false, + }, }, instructionText: { step1: s__( @@ -57,12 +66,15 @@ export default { </script> <template> <gl-modal + :visible="visible" :modal-id="modalId" :title="$options.modalInfo.title" + static size="lg" ok-only ok-variant="light" :ok-title="$options.modalInfo.closeText" + @change="$emit('change', $event)" > <p> <gl-sprintf :message="$options.instructionText.step1"> diff --git a/app/assets/javascripts/environments/components/environment_delete.vue b/app/assets/javascripts/environments/components/environment_delete.vue index 8609503e486..63169b790c7 100644 --- a/app/assets/javascripts/environments/components/environment_delete.vue +++ b/app/assets/javascripts/environments/components/environment_delete.vue @@ -7,6 +7,7 @@ import { GlDropdownItem, GlModalDirective } from '@gitlab/ui'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; +import setEnvironmentToDelete from '../graphql/mutations/set_environment_to_delete.mutation.graphql'; export default { components: { @@ -20,6 +21,11 @@ export default { type: Object, required: true, }, + graphql: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -30,14 +36,25 @@ export default { title: s__('Environments|Delete environment'), }, mounted() { - eventHub.$on('deleteEnvironment', this.onDeleteEnvironment); + if (!this.graphql) { + eventHub.$on('deleteEnvironment', this.onDeleteEnvironment); + } }, beforeDestroy() { - eventHub.$off('deleteEnvironment', this.onDeleteEnvironment); + if (!this.graphql) { + eventHub.$off('deleteEnvironment', this.onDeleteEnvironment); + } }, methods: { onClick() { - eventHub.$emit('requestDeleteEnvironment', this.environment); + if (this.graphql) { + this.$apollo.mutate({ + mutation: setEnvironmentToDelete, + variables: { environment: this.environment }, + }); + } else { + eventHub.$emit('requestDeleteEnvironment', this.environment); + } }, onDeleteEnvironment(environment) { if (this.environment.id === environment.id) { diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index db01d455b2b..be9bfb50de5 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -5,7 +5,7 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { __, s__, sprintf } from '~/locale'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import CommitComponent from '~/vue_shared/components/commit.vue'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import eventHub from '../event_hub'; diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue index 00497b3c683..f7f0cf4cb8d 100644 --- a/app/assets/javascripts/environments/components/environment_rollback.vue +++ b/app/assets/javascripts/environments/components/environment_rollback.vue @@ -8,6 +8,7 @@ import { GlModalDirective, GlDropdownItem } from '@gitlab/ui'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; +import setEnvironmentToRollback from '../graphql/mutations/set_environment_to_rollback.mutation.graphql'; export default { components: { @@ -32,11 +33,12 @@ export default { type: String, required: true, }, - }, - data() { - return { - isLoading: false, - }; + + graphql: { + type: Boolean, + required: false, + default: false, + }, }, computed: { @@ -49,16 +51,18 @@ export default { methods: { onClick() { - eventHub.$emit('requestRollbackEnvironment', { - ...this.environment, - retryUrl: this.retryUrl, - isLastDeployment: this.isLastDeployment, - }); - eventHub.$on('rollbackEnvironment', (environment) => { - if (environment.id === this.environment.id) { - this.isLoading = true; - } - }); + if (this.graphql) { + this.$apollo.mutate({ + mutation: setEnvironmentToRollback, + variables: { environment: this.environment }, + }); + } else { + eventHub.$emit('requestRollbackEnvironment', { + ...this.environment, + retryUrl: this.retryUrl, + isLastDeployment: this.isLastDeployment, + }); + } }, }, }; diff --git a/app/assets/javascripts/environments/components/new_environment_folder.vue b/app/assets/javascripts/environments/components/new_environment_folder.vue index 0615bdef537..fe3d6f1e8ca 100644 --- a/app/assets/javascripts/environments/components/new_environment_folder.vue +++ b/app/assets/javascripts/environments/components/new_environment_folder.vue @@ -1,9 +1,11 @@ <script> -import { GlCollapse, GlIcon, GlBadge, GlLink } from '@gitlab/ui'; +import { GlButton, GlCollapse, GlIcon, GlBadge, GlLink } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; import folderQuery from '../graphql/queries/folder.query.graphql'; export default { components: { + GlButton, GlCollapse, GlIcon, GlBadge, @@ -26,12 +28,20 @@ export default { }, }, }, + i18n: { + collapse: __('Collapse'), + expand: __('Expand'), + link: s__('Environments|Show all'), + }, computed: { icons() { return this.visible ? { caret: 'angle-down', folder: 'folder-open' } : { caret: 'angle-right', folder: 'folder-o' }; }, + label() { + return this.visible ? this.$options.i18n.collapse : this.$options.i18n.expand; + }, count() { return this.folder?.availableCount ?? 0; }, @@ -51,18 +61,21 @@ export default { </script> <template> <div class="gl-border-b-solid gl-border-gray-100 gl-border-1 gl-px-3 gl-pt-3 gl-pb-5"> - <div class="gl-w-full gl-display-flex gl-align-items-center" @click="toggleCollapse"> - <gl-icon - class="gl-mr-2 gl-fill-current-color gl-text-gray-500" - :name="icons.caret" - :size="12" + <div class="gl-w-full gl-display-flex gl-align-items-center"> + <gl-button + class="gl-mr-4 gl-fill-current-color gl-text-gray-500" + :aria-label="label" + :icon="icons.caret" + size="small" + category="tertiary" + @click="toggleCollapse" /> <gl-icon class="gl-mr-2 gl-fill-current-color gl-text-gray-500" :name="icons.folder" /> <div class="gl-mr-2 gl-text-gray-500" :class="folderClass"> {{ nestedEnvironment.name }} </div> <gl-badge size="sm" class="gl-mr-auto">{{ count }}</gl-badge> - <gl-link v-if="visible" :href="folderPath">{{ s__('Environments|Show all') }}</gl-link> + <gl-link v-if="visible" :href="folderPath">{{ $options.i18n.link }}</gl-link> </div> <gl-collapse :visible="visible" /> </div> diff --git a/app/assets/javascripts/environments/components/new_environments_app.vue b/app/assets/javascripts/environments/components/new_environments_app.vue index a5526f9cd71..8d94e7021ca 100644 --- a/app/assets/javascripts/environments/components/new_environments_app.vue +++ b/app/assets/javascripts/environments/components/new_environments_app.vue @@ -1,47 +1,205 @@ <script> -import { GlBadge, GlTab, GlTabs } from '@gitlab/ui'; -import environmentAppQuery from '../graphql/queries/environmentApp.query.graphql'; +import { GlBadge, GlPagination, GlTab, GlTabs } from '@gitlab/ui'; +import { s__, __, sprintf } from '~/locale'; +import { updateHistory, setUrlParams, queryToObject } from '~/lib/utils/url_utility'; +import environmentAppQuery from '../graphql/queries/environment_app.query.graphql'; +import pollIntervalQuery from '../graphql/queries/poll_interval.query.graphql'; +import pageInfoQuery from '../graphql/queries/page_info.query.graphql'; import EnvironmentFolder from './new_environment_folder.vue'; +import EnableReviewAppModal from './enable_review_app_modal.vue'; export default { components: { EnvironmentFolder, + EnableReviewAppModal, GlBadge, + GlPagination, GlTab, GlTabs, }, apollo: { environmentApp: { query: environmentAppQuery, + variables() { + return { + scope: this.scope, + page: this.page ?? 1, + }; + }, + pollInterval() { + return this.interval; + }, }, + interval: { + query: pollIntervalQuery, + }, + pageInfo: { + query: pageInfoQuery, + }, + }, + inject: ['newEnvironmentPath', 'canCreateEnvironment'], + i18n: { + newEnvironmentButtonLabel: s__('Environments|New environment'), + reviewAppButtonLabel: s__('Environments|Enable review app'), + available: __('Available'), + stopped: __('Stopped'), + prevPage: __('Go to previous page'), + nextPage: __('Go to next page'), + next: __('Next'), + prev: __('Prev'), + goto: (page) => sprintf(__('Go to page %{page}'), { page }), + }, + modalId: 'enable-review-app-info', + data() { + const { page = '1', scope = 'available' } = queryToObject(window.location.search); + return { + interval: undefined, + isReviewAppModalVisible: false, + page: parseInt(page, 10), + scope, + }; }, computed: { + canSetupReviewApp() { + return this.environmentApp?.reviewApp?.canSetupReviewApp; + }, folders() { return this.environmentApp?.environments.filter((e) => e.size > 1) ?? []; }, availableCount() { return this.environmentApp?.availableCount; }, + addEnvironment() { + if (!this.canCreateEnvironment) { + return null; + } + + return { + text: this.$options.i18n.newEnvironmentButtonLabel, + attributes: { + href: this.newEnvironmentPath, + category: 'primary', + variant: 'confirm', + }, + }; + }, + openReviewAppModal() { + if (!this.canSetupReviewApp) { + return null; + } + + return { + text: this.$options.i18n.reviewAppButtonLabel, + attributes: { + category: 'secondary', + variant: 'confirm', + }, + }; + }, + stoppedCount() { + return this.environmentApp?.stoppedCount; + }, + totalItems() { + return this.pageInfo?.total; + }, + itemsPerPage() { + return this.pageInfo?.perPage; + }, + }, + mounted() { + window.addEventListener('popstate', this.syncPageFromQueryParams); + }, + destroyed() { + window.removeEventListener('popstate', this.syncPageFromQueryParams); + this.$apollo.queries.environmentApp.stopPolling(); + }, + methods: { + showReviewAppModal() { + this.isReviewAppModalVisible = true; + }, + setScope(scope) { + this.scope = scope; + this.resetPolling(); + }, + movePage(direction) { + this.moveToPage(this.pageInfo[`${direction}Page`]); + }, + moveToPage(page) { + this.page = page; + updateHistory({ + url: setUrlParams({ page: this.page }), + title: document.title, + }); + this.resetPolling(); + }, + syncPageFromQueryParams() { + const { page = '1' } = queryToObject(window.location.search); + this.page = parseInt(page, 10); + }, + resetPolling() { + this.$apollo.queries.environmentApp.stopPolling(); + this.$nextTick(() => { + if (this.interval) { + this.$apollo.queries.environmentApp.startPolling(this.interval); + } else { + this.$apollo.queries.environmentApp.refetch({ scope: this.scope, page: this.page }); + } + }); + }, }, }; </script> <template> <div> - <gl-tabs> - <gl-tab> + <enable-review-app-modal + v-if="canSetupReviewApp" + v-model="isReviewAppModalVisible" + :modal-id="$options.modalId" + data-testid="enable-review-app-modal" + /> + <gl-tabs + :action-secondary="addEnvironment" + :action-primary="openReviewAppModal" + sync-active-tab-with-query-params + query-param-name="scope" + @primary="showReviewAppModal" + > + <gl-tab query-param-value="available" @click="setScope('available')"> <template #title> - <span>{{ __('Available') }}</span> + <span>{{ $options.i18n.available }}</span> <gl-badge size="sm" class="gl-tab-counter-badge"> {{ availableCount }} </gl-badge> </template> - <environment-folder - v-for="folder in folders" - :key="folder.name" - class="gl-mb-3" - :nested-environment="folder" - /> + </gl-tab> + <gl-tab query-param-value="stopped" @click="setScope('stopped')"> + <template #title> + <span>{{ $options.i18n.stopped }}</span> + <gl-badge size="sm" class="gl-tab-counter-badge"> + {{ stoppedCount }} + </gl-badge> + </template> </gl-tab> </gl-tabs> + <environment-folder + v-for="folder in folders" + :key="folder.name" + class="gl-mb-3" + :nested-environment="folder" + /> + <gl-pagination + align="center" + :total-items="totalItems" + :per-page="itemsPerPage" + :value="page" + :next="$options.i18n.next" + :prev="$options.i18n.prev" + :label-previous-page="$options.prevPage" + :label-next-page="$options.nextPage" + :label-page="$options.goto" + @next="movePage('next')" + @previous="movePage('previous')" + @input="moveToPage" + /> </div> </template> diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js index c734c2fba0c..64b18c2003b 100644 --- a/app/assets/javascripts/environments/graphql/client.js +++ b/app/assets/javascripts/environments/graphql/client.js @@ -1,6 +1,7 @@ import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import environmentApp from './queries/environmentApp.query.graphql'; +import environmentApp from './queries/environment_app.query.graphql'; +import pageInfoQuery from './queries/page_info.query.graphql'; import { resolvers } from './resolvers'; import typeDefs from './typedefs.graphql'; @@ -19,6 +20,19 @@ export const apolloProvider = (endpoint) => { stoppedCount: 0, }, }); + + cache.writeQuery({ + query: pageInfoQuery, + data: { + pageInfo: { + total: 0, + perPage: 20, + nextPage: 0, + previousPage: 0, + __typename: 'LocalPageInfo', + }, + }, + }); return new VueApollo({ defaultClient, }); diff --git a/app/assets/javascripts/environments/graphql/mutations/set_environment_to_delete.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/set_environment_to_delete.mutation.graphql new file mode 100644 index 00000000000..ea72067bd37 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/mutations/set_environment_to_delete.mutation.graphql @@ -0,0 +1,3 @@ +mutation SetEnvironmentToDelete($environment: Environment) { + setEnvironmentToDelete(environment: $environment) @client +} diff --git a/app/assets/javascripts/environments/graphql/mutations/set_environment_to_rollback.mutation.graphql b/app/assets/javascripts/environments/graphql/mutations/set_environment_to_rollback.mutation.graphql new file mode 100644 index 00000000000..aba978ed79e --- /dev/null +++ b/app/assets/javascripts/environments/graphql/mutations/set_environment_to_rollback.mutation.graphql @@ -0,0 +1,3 @@ +mutation SetEnvironmentToRollback($environment: Environment) { + setEnvironmentToRollback(environment: $environment) @client +} diff --git a/app/assets/javascripts/environments/graphql/queries/environmentApp.query.graphql b/app/assets/javascripts/environments/graphql/queries/environmentApp.query.graphql deleted file mode 100644 index faa76c0a42c..00000000000 --- a/app/assets/javascripts/environments/graphql/queries/environmentApp.query.graphql +++ /dev/null @@ -1,8 +0,0 @@ -query getEnvironmentApp { - environmentApp @client { - availableCount - environments - reviewApp - stoppedCount - } -} diff --git a/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql new file mode 100644 index 00000000000..2c17c42dd6d --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql @@ -0,0 +1,9 @@ +query getEnvironmentApp($page: Int, $scope: String) { + environmentApp(page: $page, scope: $scope) @client { + availableCount + stoppedCount + environments + reviewApp + stoppedCount + } +} diff --git a/app/assets/javascripts/environments/graphql/queries/environment_to_delete.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_to_delete.query.graphql new file mode 100644 index 00000000000..5d39de8a0f1 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/environment_to_delete.query.graphql @@ -0,0 +1,7 @@ +query environmentToDelete { + environmentToDelete @client { + id + name + deletePath + } +} diff --git a/app/assets/javascripts/environments/graphql/queries/environment_to_rollback.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_to_rollback.query.graphql new file mode 100644 index 00000000000..f7586e27665 --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/environment_to_rollback.query.graphql @@ -0,0 +1,7 @@ +query environmentToRollback { + environmentToRollback @client { + id + name + lastDeployment + } +} diff --git a/app/assets/javascripts/environments/graphql/queries/page_info.query.graphql b/app/assets/javascripts/environments/graphql/queries/page_info.query.graphql new file mode 100644 index 00000000000..d77ca05d46f --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/page_info.query.graphql @@ -0,0 +1,8 @@ +query getPageInfo { + pageInfo @client { + total + perPage + nextPage + previousPage + } +} diff --git a/app/assets/javascripts/environments/graphql/queries/poll_interval.query.graphql b/app/assets/javascripts/environments/graphql/queries/poll_interval.query.graphql new file mode 100644 index 00000000000..28afc30a0dd --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/poll_interval.query.graphql @@ -0,0 +1,3 @@ +query pollInterval { + interval @client +} diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js index 8322b806370..9ebbc0ad1f8 100644 --- a/app/assets/javascripts/environments/graphql/resolvers.js +++ b/app/assets/javascripts/environments/graphql/resolvers.js @@ -1,5 +1,20 @@ import axios from '~/lib/utils/axios_utils'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { s__ } from '~/locale'; +import { + convertObjectPropsToCamelCase, + parseIntPagination, + normalizeHeaders, +} from '~/lib/utils/common_utils'; + +import pollIntervalQuery from './queries/poll_interval.query.graphql'; +import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql'; +import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql'; +import pageInfoQuery from './queries/page_info.query.graphql'; + +const buildErrors = (errors = []) => ({ + errors, + __typename: 'LocalEnvironmentErrors', +}); const mapNestedEnvironment = (env) => ({ ...convertObjectPropsToCamelCase(env, { deep: true }), @@ -12,17 +27,34 @@ const mapEnvironment = (env) => ({ export const resolvers = (endpoint) => ({ Query: { - environmentApp() { - return axios.get(endpoint, { params: { nested: true } }).then((res) => ({ - availableCount: res.data.available_count, - environments: res.data.environments.map(mapNestedEnvironment), - reviewApp: { - ...convertObjectPropsToCamelCase(res.data.review_app), - __typename: 'ReviewApp', - }, - stoppedCount: res.data.stopped_count, - __typename: 'LocalEnvironmentApp', - })); + environmentApp(_context, { page, scope }, { cache }) { + return axios.get(endpoint, { params: { nested: true, page, scope } }).then((res) => { + const headers = normalizeHeaders(res.headers); + const interval = headers['POLL-INTERVAL']; + const pageInfo = { ...parseIntPagination(headers), __typename: 'LocalPageInfo' }; + + if (interval) { + cache.writeQuery({ query: pollIntervalQuery, data: { interval: parseFloat(interval) } }); + } else { + cache.writeQuery({ query: pollIntervalQuery, data: { interval: undefined } }); + } + + cache.writeQuery({ + query: pageInfoQuery, + data: { pageInfo }, + }); + + return { + availableCount: res.data.available_count, + environments: res.data.environments.map(mapNestedEnvironment), + reviewApp: { + ...convertObjectPropsToCamelCase(res.data.review_app), + __typename: 'ReviewApp', + }, + stoppedCount: res.data.stopped_count, + __typename: 'LocalEnvironmentApp', + }; + }); }, folder(_, { environment: { folderPath } }) { return axios.get(folderPath, { params: { per_page: 3 } }).then((res) => ({ @@ -32,19 +64,72 @@ export const resolvers = (endpoint) => ({ __typename: 'LocalEnvironmentFolder', })); }, + isLastDeployment(_, { environment }) { + // eslint-disable-next-line @gitlab/require-i18n-strings + return environment?.lastDeployment?.['last?']; + }, }, - Mutations: { - stopEnvironment(_, { environment: { stopPath } }) { - return axios.post(stopPath); + Mutation: { + stopEnvironment(_, { environment }) { + return axios + .post(environment.stopPath) + .then(() => buildErrors()) + .catch(() => { + return buildErrors([ + s__('Environments|An error occurred while stopping the environment, please try again'), + ]); + }); }, deleteEnvironment(_, { environment: { deletePath } }) { - return axios.delete(deletePath); + return axios + .delete(deletePath) + .then(() => buildErrors()) + .catch(() => + buildErrors([ + s__( + 'Environments|An error occurred while deleting the environment. Check if the environment stopped; if not, stop it and try again.', + ), + ]), + ); + }, + rollbackEnvironment(_, { environment, isLastDeployment }) { + return axios + .post(environment?.retryUrl) + .then(() => buildErrors()) + .catch(() => { + buildErrors([ + isLastDeployment + ? s__( + 'Environments|An error occurred while re-deploying the environment, please try again', + ) + : s__( + 'Environments|An error occurred while rolling back the environment, please try again', + ), + ]); + }); + }, + setEnvironmentToDelete(_, { environment }, { client }) { + client.writeQuery({ + query: environmentToDeleteQuery, + data: { environmentToDelete: environment }, + }); }, - rollbackEnvironment(_, { environment: { retryUrl } }) { - return axios.post(retryUrl); + setEnvironmentToRollback(_, { environment }, { client }) { + client.writeQuery({ + query: environmentToRollbackQuery, + data: { environmentToRollback: environment }, + }); }, cancelAutoStop(_, { environment: { autoStopPath } }) { - return axios.post(autoStopPath); + return axios + .post(autoStopPath) + .then(() => buildErrors()) + .catch((err) => + buildErrors([ + err?.response?.data?.message || + s__('Environments|An error occurred while canceling the auto stop, please try again'), + ]), + ); }, }, }); diff --git a/app/assets/javascripts/environments/graphql/typedefs.graphql b/app/assets/javascripts/environments/graphql/typedefs.graphql index 49ea719449e..4a3abb0e89f 100644 --- a/app/assets/javascripts/environments/graphql/typedefs.graphql +++ b/app/assets/javascripts/environments/graphql/typedefs.graphql @@ -9,12 +9,29 @@ type LocalEnvironment { autoStopPath: String } +input LocalEnvironmentInput { + id: Int! + globalId: ID! + name: String! + folderPath: String + stopPath: String + deletePath: String + retryUrl: String + autoStopPath: String +} + type NestedLocalEnvironment { name: String! size: Int! latest: LocalEnvironment! } +input NestedLocalEnvironmentInput { + name: String! + size: Int! + latest: LocalEnvironmentInput! +} + type LocalEnvironmentFolder { environments: [LocalEnvironment!]! availableCount: Int! @@ -33,3 +50,32 @@ type LocalEnvironmentApp { environments: [NestedLocalEnvironment!]! reviewApp: ReviewApp! } + +type LocalErrors { + errors: [String!]! +} + +type LocalPageInfo { + total: Int! + perPage: Int! + nextPage: Int! + previousPage: Int! +} + +extend type Query { + environmentApp(page: Int, scope: String): LocalEnvironmentApp + folder(environment: NestedLocalEnvironmentInput): LocalEnvironmentFolder + environmentToDelete: LocalEnvironment + pageInfo: LocalPageInfo + environmentToRollback: LocalEnvironment + isLastDeployment: Boolean +} + +extend type Mutation { + stopEnvironment(environment: LocalEnvironmentInput): LocalErrors + deleteEnvironment(environment: LocalEnvironmentInput): LocalErrors + rollbackEnvironment(environment: LocalEnvironmentInput): LocalErrors + cancelAutoStop(environment: LocalEnvironmentInput): LocalErrors + setEnvironmentToDelete(environment: LocalEnvironmentInput): LocalErrors + setEnvironmentToRollback(environment: LocalEnvironmentInput): LocalErrors +} diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index 4adbf5362b7..e00fec6fddf 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -17,7 +17,7 @@ import createFlash from '~/flash'; import { __, sprintf, n__ } from '~/locale'; import Tracking from '~/tracking'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import TrackEventDirective from '~/vue_shared/directives/track_event'; import query from '../queries/details.query.graphql'; import { diff --git a/app/assets/javascripts/error_tracking/queries/details.query.graphql b/app/assets/javascripts/error_tracking/queries/details.query.graphql index af386528f00..f70e09d76f7 100644 --- a/app/assets/javascripts/error_tracking/queries/details.query.graphql +++ b/app/assets/javascripts/error_tracking/queries/details.query.graphql @@ -1,5 +1,6 @@ query errorDetails($fullPath: ID!, $errorId: ID!) { project(fullPath: $fullPath) { + id sentryErrors { detailedError(id: $errorId) { id diff --git a/app/assets/javascripts/experimentation/utils.js b/app/assets/javascripts/experimentation/utils.js index dcb6a8e20a3..69fa7adc653 100644 --- a/app/assets/javascripts/experimentation/utils.js +++ b/app/assets/javascripts/experimentation/utils.js @@ -1,5 +1,5 @@ -// This file only applies to use of experiments through https://gitlab.com/gitlab-org/gitlab-experiment -import { get } from 'lodash'; +// This file only applies to use of experiments through https://gitlab.com/gitlab-org/ruby/gems/gitlab-experiment +import { get, mapValues, pick } from 'lodash'; import { DEFAULT_VARIANT, CANDIDATE_VARIANT, TRACKING_CONTEXT_SCHEMA } from './constants'; function getExperimentsData() { @@ -8,19 +8,18 @@ function getExperimentsData() { // Pull from preferred window.gl.experiments const experimentsFromGl = get(window, ['gl', 'experiments'], {}); - return { ...experimentsFromGon, ...experimentsFromGl }; -} - -function convertExperimentDataToExperimentContext(experimentData) { - // Bandaid to allow-list only the properties which the current gitlab_experiment context schema suppports. + // Bandaid to allow-list only the properties which the current gitlab_experiment + // context schema suppports, since we most often use this data to create that + // Snowplow context. // See TRACKING_CONTEXT_SCHEMA for current version (1-0-0) // https://gitlab.com/gitlab-org/iglu/-/blob/master/public/schemas/com.gitlab/gitlab_experiment/jsonschema/1-0-0 - const { experiment: experimentName, key, variant, migration_keys } = experimentData; + return mapValues({ ...experimentsFromGon, ...experimentsFromGl }, (xp) => { + return pick(xp, ['experiment', 'key', 'variant', 'migration_keys']); + }); +} - return { - schema: TRACKING_CONTEXT_SCHEMA, - data: { experiment: experimentName, key, variant, migration_keys }, - }; +function createGitlabExperimentContext(experimentData) { + return { schema: TRACKING_CONTEXT_SCHEMA, data: experimentData }; } export function getExperimentData(experimentName) { @@ -28,10 +27,10 @@ export function getExperimentData(experimentName) { } export function getAllExperimentContexts() { - return Object.values(getExperimentsData()).map(convertExperimentDataToExperimentContext); + return Object.values(getExperimentsData()).map(createGitlabExperimentContext); } -export function isExperimentVariant(experimentName, variantName) { +export function isExperimentVariant(experimentName, variantName = CANDIDATE_VARIANT) { return getExperimentData(experimentName)?.variant === variantName; } diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue index 29e82289107..26da0d56f9a 100644 --- a/app/assets/javascripts/feature_flags/components/form.vue +++ b/app/assets/javascripts/feature_flags/components/form.vue @@ -142,7 +142,14 @@ export default { return !this.$options.rolloutPercentageRegex.test(percentage); }), onFormStrategyChange(strategy, index) { + const currentUserListId = this.filteredStrategies[index]?.userList?.id; + const newUserListId = strategy?.userList?.id; + Object.assign(this.filteredStrategies[index], strategy); + + if (currentUserListId !== newUserListId) { + this.formStrategies = [...this.formStrategies]; + } }, }, }; diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js index e0281b8f443..3cd4d48a4a3 100644 --- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js +++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js @@ -1,4 +1,4 @@ -import { sortMilestonesByDueDate } from '~/milestones/milestone_utils'; +import { sortMilestonesByDueDate } from '~/milestones/utils'; import { mergeUrlParams } from '../lib/utils/url_utility'; import DropdownAjaxFilter from './dropdown_ajax_filter'; import DropdownEmoji from './dropdown_emoji'; diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js index 08736b09407..e2d6936acbd 100644 --- a/app/assets/javascripts/filtered_search/constants.js +++ b/app/assets/javascripts/filtered_search/constants.js @@ -11,3 +11,10 @@ export const FILTER_TYPE = { }; export const MAX_HISTORY_SIZE = 5; + +export const FILTERED_SEARCH = { + MERGE_REQUESTS: 'merge_requests', + ISSUES: 'issues', + ADMIN_RUNNERS: 'admin/runners', + GROUP_RUNNERS_ANCHOR: 'runners-settings', +}; diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index 1287a7ed746..f0ef55f73eb 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -62,7 +62,7 @@ const createFlashEl = (message, type) => ` </div> `; -const removeFlashClickListener = (flashEl, fadeTransition) => { +const addDismissFlashClickListener = (flashEl, fadeTransition) => { // There are some flash elements which do not have a closeEl. // https://gitlab.com/gitlab-org/gitlab/blob/763426ef344488972eb63ea5be8744e0f8459e6b/ee/app/views/layouts/header/_read_only_banner.html.haml getCloseEl(flashEl)?.addEventListener('click', () => hideFlash(flashEl, fadeTransition)); @@ -113,7 +113,7 @@ const createFlash = function createFlash({ } } - removeFlashClickListener(flashEl, fadeTransition); + addDismissFlashClickListener(flashEl, fadeTransition); flashContainer.classList.add('gl-display-block'); @@ -130,10 +130,8 @@ const createFlash = function createFlash({ export { createFlash as default, - createFlashEl, - createAction, hideFlash, - removeFlashClickListener, + addDismissFlashClickListener, FLASH_TYPES, FLASH_CLOSED_EVENT, }; diff --git a/app/assets/javascripts/google_cloud/components/app.vue b/app/assets/javascripts/google_cloud/components/app.vue index 1e5be9df019..64784755b66 100644 --- a/app/assets/javascripts/google_cloud/components/app.vue +++ b/app/assets/javascripts/google_cloud/components/app.vue @@ -1,22 +1,42 @@ <script> -import { GlTab, GlTabs } from '@gitlab/ui'; +import { __ } from '~/locale'; + +import Home from './home.vue'; import IncubationBanner from './incubation_banner.vue'; -import ServiceAccounts from './service_accounts.vue'; +import ServiceAccountsForm from './service_accounts_form.vue'; +import NoGcpProjects from './errors/no_gcp_projects.vue'; +import GcpError from './errors/gcp_error.vue'; + +const SCREEN_GCP_ERROR = 'gcp_error'; +const SCREEN_HOME = 'home'; +const SCREEN_NO_GCP_PROJECTS = 'no_gcp_projects'; +const SCREEN_SERVICE_ACCOUNTS_FORM = 'service_accounts_form'; export default { - components: { GlTab, GlTabs, IncubationBanner, ServiceAccounts }, + components: { + IncubationBanner, + }, + inheritAttrs: false, props: { - serviceAccounts: { - type: Array, + screen: { required: true, - }, - createServiceAccountUrl: { type: String, - required: true, }, - emptyIllustrationUrl: { - type: String, - required: true, + }, + computed: { + mainComponent() { + switch (this.screen) { + case SCREEN_HOME: + return Home; + case SCREEN_GCP_ERROR: + return GcpError; + case SCREEN_NO_GCP_PROJECTS: + return NoGcpProjects; + case SCREEN_SERVICE_ACCOUNTS_FORM: + return ServiceAccountsForm; + default: + throw new Error(__('Unknown screen')); + } }, }, methods: { @@ -34,17 +54,6 @@ export default { :report-bug-url="feedbackUrl('report_bug')" :feature-request-url="feedbackUrl('feature_request')" /> - <gl-tabs> - <gl-tab :title="__('Configuration')"> - <service-accounts - class="gl-mx-3" - :list="serviceAccounts" - :create-url="createServiceAccountUrl" - :empty-illustration-url="emptyIllustrationUrl" - /> - </gl-tab> - <gl-tab :title="__('Deployments')" disabled /> - <gl-tab :title="__('Services')" disabled /> - </gl-tabs> + <component :is="mainComponent" v-bind="$attrs" /> </div> </template> diff --git a/app/assets/javascripts/google_cloud/components/errors/gcp_error.vue b/app/assets/javascripts/google_cloud/components/errors/gcp_error.vue new file mode 100644 index 00000000000..90aa0e1ae68 --- /dev/null +++ b/app/assets/javascripts/google_cloud/components/errors/gcp_error.vue @@ -0,0 +1,29 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { GlAlert }, + props: { + error: { + type: String, + required: true, + }, + }, + i18n: { + title: __('Google Cloud project misconfigured'), + description: __( + 'GitLab and Google Cloud configuration seems to be incomplete. This probably can be fixed by your GitLab administration team. You may share these logs with them:', + ), + }, +}; +</script> + +<template> + <gl-alert :dismissible="false" variant="warning" :title="$options.i18n.title"> + {{ $options.i18n.description }} + <blockquote> + <code>{{ error }}</code> + </blockquote> + </gl-alert> +</template> diff --git a/app/assets/javascripts/google_cloud/components/errors/no_gcp_projects.vue b/app/assets/javascripts/google_cloud/components/errors/no_gcp_projects.vue new file mode 100644 index 00000000000..da229ac3f0e --- /dev/null +++ b/app/assets/javascripts/google_cloud/components/errors/no_gcp_projects.vue @@ -0,0 +1,26 @@ +<script> +import { GlAlert, GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { GlAlert, GlButton }, + i18n: { + title: __('Google Cloud project required'), + description: __( + 'You do not have any Google Cloud projects. Please create a Google Cloud project and then reload this page.', + ), + createLabel: __('Create Google Cloud project'), + }, +}; +</script> + +<template> + <gl-alert :dismissible="false" variant="warning" :title="$options.i18n.title"> + {{ $options.i18n.description }} + <template #actions> + <gl-button href="https://console.cloud.google.com/projectcreate" target="_blank"> + {{ $options.i18n.createLabel }} + </gl-button> + </template> + </gl-alert> +</template> diff --git a/app/assets/javascripts/google_cloud/components/home.vue b/app/assets/javascripts/google_cloud/components/home.vue new file mode 100644 index 00000000000..05f39de66ee --- /dev/null +++ b/app/assets/javascripts/google_cloud/components/home.vue @@ -0,0 +1,41 @@ +<script> +import { GlTabs, GlTab } from '@gitlab/ui'; +import ServiceAccountsList from './service_accounts_list.vue'; + +export default { + components: { + GlTabs, + GlTab, + ServiceAccountsList, + }, + props: { + serviceAccounts: { + type: Array, + required: true, + }, + createServiceAccountUrl: { + type: String, + required: true, + }, + emptyIllustrationUrl: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <gl-tabs> + <gl-tab :title="__('Configuration')"> + <service-accounts-list + class="gl-mx-4" + :list="serviceAccounts" + :create-url="createServiceAccountUrl" + :empty-illustration-url="emptyIllustrationUrl" + /> + </gl-tab> + <gl-tab :title="__('Deployments')" disabled /> + <gl-tab :title="__('Services')" disabled /> + </gl-tabs> +</template> diff --git a/app/assets/javascripts/google_cloud/components/service_accounts_form.vue b/app/assets/javascripts/google_cloud/components/service_accounts_form.vue new file mode 100644 index 00000000000..e7a09668473 --- /dev/null +++ b/app/assets/javascripts/google_cloud/components/service_accounts_form.vue @@ -0,0 +1,70 @@ +<script> +import { GlButton, GlFormGroup, GlFormSelect } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { GlButton, GlFormGroup, GlFormSelect }, + props: { + gcpProjects: { required: true, type: Array }, + environments: { required: true, type: Array }, + cancelPath: { required: true, type: String }, + }, + i18n: { + title: __('Create service account'), + gcpProjectLabel: __('Google Cloud project'), + gcpProjectDescription: __( + 'New service account is generated for the selected Google Cloud project', + ), + environmentLabel: __('Environment'), + environmentDescription: __('Generated service account is linked to the selected environment'), + submitLabel: __('Create service account'), + cancelLabel: __('Cancel'), + }, +}; +</script> + +<template> + <div> + <header class="gl-my-5 gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid"> + <h2 class="gl-font-size-h1">{{ $options.i18n.title }}</h2> + </header> + <gl-form-group + label-for="gcp_project" + :label="$options.i18n.gcpProjectLabel" + :description="$options.i18n.gcpProjectDescription" + > + <gl-form-select id="gcp_project" name="gcp_project" required> + <option + v-for="gcpProject in gcpProjects" + :key="gcpProject.project_id" + :value="gcpProject.project_id" + > + {{ gcpProject.name }} + </option> + </gl-form-select> + </gl-form-group> + <gl-form-group + label-for="environment" + :label="$options.i18n.environmentLabel" + :description="$options.i18n.environmentDescription" + > + <gl-form-select id="environment" name="environment" required> + <option value="*">{{ __('All') }}</option> + <option + v-for="environment in environments" + :key="environment.name" + :value="environment.name" + > + {{ environment.name }} + </option> + </gl-form-select> + </gl-form-group> + + <div class="form-actions row"> + <gl-button type="submit" category="primary" variant="confirm"> + {{ $options.i18n.submitLabel }} + </gl-button> + <gl-button class="gl-ml-1" :href="cancelPath">{{ $options.i18n.cancelLabel }}</gl-button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/google_cloud/components/service_accounts.vue b/app/assets/javascripts/google_cloud/components/service_accounts_list.vue index b70b25a5dc3..b70b25a5dc3 100644 --- a/app/assets/javascripts/google_cloud/components/service_accounts.vue +++ b/app/assets/javascripts/google_cloud/components/service_accounts_list.vue diff --git a/app/assets/javascripts/google_cloud/index.js b/app/assets/javascripts/google_cloud/index.js index a156a632e9a..ab9e8227812 100644 --- a/app/assets/javascripts/google_cloud/index.js +++ b/app/assets/javascripts/google_cloud/index.js @@ -1,11 +1,12 @@ import Vue from 'vue'; import App from './components/app.vue'; -const elementRenderer = (element, props = {}) => (createElement) => - createElement(element, { props }); - export default () => { - const root = document.querySelector('#js-google-cloud'); - const props = JSON.parse(root.getAttribute('data')); - return new Vue({ el: root, render: elementRenderer(App, props) }); + const root = '#js-google-cloud'; + const element = document.querySelector(root); + const { screen, ...attrs } = JSON.parse(element.getAttribute('data')); + return new Vue({ + el: element, + render: (createElement) => createElement(App, { props: { screen }, attrs }), + }); }; diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js index 692de9dcb88..3b36c3e6ac5 100644 --- a/app/assets/javascripts/graphql_shared/constants.js +++ b/app/assets/javascripts/graphql_shared/constants.js @@ -1,6 +1,8 @@ export const MINIMUM_SEARCH_LENGTH = 3; export const TYPE_CI_RUNNER = 'Ci::Runner'; +export const TYPE_CRM_CONTACT = 'CustomerRelations::Contact'; +export const TYPE_DISCUSSION = 'Discussion'; export const TYPE_EPIC = 'Epic'; export const TYPE_GROUP = 'Group'; export const TYPE_ISSUE = 'Issue'; @@ -8,10 +10,10 @@ export const TYPE_ITERATION = 'Iteration'; export const TYPE_ITERATIONS_CADENCE = 'Iterations::Cadence'; export const TYPE_MERGE_REQUEST = 'MergeRequest'; export const TYPE_MILESTONE = 'Milestone'; +export const TYPE_NOTE = 'Note'; +export const TYPE_PACKAGES_PACKAGE = 'Packages::Package'; export const TYPE_PROJECT = 'Project'; export const TYPE_SCANNER_PROFILE = 'DastScannerProfile'; export const TYPE_SITE_PROFILE = 'DastSiteProfile'; export const TYPE_USER = 'User'; export const TYPE_VULNERABILITY = 'Vulnerability'; -export const TYPE_NOTE = 'Note'; -export const TYPE_DISCUSSION = 'Discussion'; diff --git a/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql index 2c771c32e16..64f547f933a 100644 --- a/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql +++ b/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql @@ -6,6 +6,7 @@ fragment AlertListItem on AlertManagementAlert { startedAt eventCount issue { + id iid state title diff --git a/app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql index 9a9ae369519..794fe0a6151 100644 --- a/app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql +++ b/app/assets/javascripts/graphql_shared/fragments/alert_detail_item.fragment.graphql @@ -12,6 +12,7 @@ fragment AlertDetailItem on AlertManagementAlert { endedAt hosts environment { + id name path } diff --git a/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql index 3551394ff97..78b2cd34a5c 100644 --- a/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql +++ b/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql @@ -1,10 +1,12 @@ fragment TimelogFragment on Timelog { timeSpent user { + id name } spentAt note { + id body } summary diff --git a/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql index 0b451262b5a..429993b37bf 100644 --- a/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql +++ b/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql @@ -1,3 +1,4 @@ +# eslint-disable-next-line @graphql-eslint/require-id-when-available fragment UserAvailability on User { status { availability diff --git a/app/assets/javascripts/graphql_shared/mutations/create_merge_request.mutation.graphql b/app/assets/javascripts/graphql_shared/mutations/create_merge_request.mutation.graphql index 79c56448b3f..2adaf24ed34 100644 --- a/app/assets/javascripts/graphql_shared/mutations/create_merge_request.mutation.graphql +++ b/app/assets/javascripts/graphql_shared/mutations/create_merge_request.mutation.graphql @@ -1,6 +1,7 @@ mutation createMergeRequest($input: MergeRequestCreateInput!) { mergeRequestCreate(input: $input) { mergeRequest { + id iid } errors diff --git a/app/assets/javascripts/graphql_shared/queries/alert_details.query.graphql b/app/assets/javascripts/graphql_shared/queries/alert_details.query.graphql index 5ee2cf7ca44..8debc6113d1 100644 --- a/app/assets/javascripts/graphql_shared/queries/alert_details.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/alert_details.query.graphql @@ -2,6 +2,7 @@ query alertDetails($fullPath: ID!, $alertId: String) { project(fullPath: $fullPath) { + id alertManagementAlerts(iid: $alertId) { nodes { ...AlertDetailItem diff --git a/app/assets/javascripts/graphql_shared/queries/get_alerts.query.graphql b/app/assets/javascripts/graphql_shared/queries/get_alerts.query.graphql index 095e4fe29df..9ffa0bad9ad 100644 --- a/app/assets/javascripts/graphql_shared/queries/get_alerts.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/get_alerts.query.graphql @@ -14,6 +14,7 @@ query getAlerts( $domain: AlertManagementDomainFilter = operations ) { project(fullPath: $projectPath) { + id alertManagementAlerts( search: $searchTerm assigneeUsername: $assigneeUsername diff --git a/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql index c5f99a1657e..7c88e494a2e 100644 --- a/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/group_users_search.query.graphql @@ -6,6 +6,7 @@ query groupUsersSearch($search: String!, $fullPath: ID!) { id users: groupMembers(search: $search, relations: [DIRECT, DESCENDANTS, INHERITED]) { nodes { + id user { ...User ...UserAvailability diff --git a/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql index 62ce27815c7..ef3070d3437 100644 --- a/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql @@ -3,6 +3,7 @@ query searchProjectMembers($fullPath: ID!, $search: String) { id projectMembers(search: $search) { nodes { + id user { id name diff --git a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql index d04a49f8b3a..bb34e4032f4 100644 --- a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql @@ -3,8 +3,10 @@ query projectUsersSearch($search: String!, $fullPath: ID!) { workspace: project(fullPath: $fullPath) { + id users: projectMembers(search: $search, relations: [DIRECT, INHERITED, INVITED_GROUPS]) { nodes { + id user { ...User ...UserAvailability diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue index c6590fd8eb3..edc6573a489 100644 --- a/app/assets/javascripts/header_search/components/app.vue +++ b/app/assets/javascripts/header_search/components/app.vue @@ -1,8 +1,17 @@ <script> import { GlSearchBoxByType, GlOutsideDirective as Outside } from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; +import { debounce } from 'lodash'; import { visitUrl } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { s__, sprintf } from '~/locale'; +import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; +import { + FIRST_DROPDOWN_INDEX, + SEARCH_BOX_INDEX, + SEARCH_INPUT_DESCRIPTION, + SEARCH_RESULTS_DESCRIPTION, +} from '../constants'; import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue'; import HeaderSearchDefaultItems from './header_search_default_items.vue'; import HeaderSearchScopedItems from './header_search_scoped_items.vue'; @@ -10,7 +19,21 @@ import HeaderSearchScopedItems from './header_search_scoped_items.vue'; export default { name: 'HeaderSearchApp', i18n: { - searchPlaceholder: __('Search or jump to...'), + searchPlaceholder: s__('GlobalSearch|Search or jump to...'), + searchAria: s__('GlobalSearch|Search GitLab'), + searchInputDescribeByNoDropdown: s__( + 'GlobalSearch|Type and press the enter key to submit search.', + ), + searchInputDescribeByWithDropdown: s__( + 'GlobalSearch|Type for new suggestions to appear below.', + ), + searchDescribedByDefault: s__( + 'GlobalSearch|%{count} default results provided. Use the up and down arrow keys to navigate search results list.', + ), + searchDescribedByUpdated: s__( + 'GlobalSearch|Results updated. %{count} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.', + ), + searchResultsLoading: s__('GlobalSearch|Search results are loading'), }, directives: { Outside }, components: { @@ -18,15 +41,17 @@ export default { HeaderSearchDefaultItems, HeaderSearchScopedItems, HeaderSearchAutocompleteItems, + DropdownKeyboardNavigation, }, data() { return { showDropdown: false, + currentFocusIndex: SEARCH_BOX_INDEX, }; }, computed: { - ...mapState(['search']), - ...mapGetters(['searchQuery']), + ...mapState(['search', 'loading']), + ...mapGetters(['searchQuery', 'searchOptions']), searchText: { get() { return this.search; @@ -35,15 +60,55 @@ export default { this.setSearch(value); }, }, + currentFocusedOption() { + return this.searchOptions[this.currentFocusIndex]; + }, + currentFocusedId() { + return this.currentFocusedOption?.html_id; + }, + isLoggedIn() { + return gon?.current_username; + }, showSearchDropdown() { - return this.showDropdown && gon?.current_username; + return this.showDropdown && this.isLoggedIn; }, showDefaultItems() { return !this.searchText; }, + defaultIndex() { + if (this.showDefaultItems) { + return SEARCH_BOX_INDEX; + } + + return FIRST_DROPDOWN_INDEX; + }, + searchInputDescribeBy() { + if (this.isLoggedIn) { + return this.$options.i18n.searchInputDescribeByWithDropdown; + } + + return this.$options.i18n.searchInputDescribeByNoDropdown; + }, + dropdownResultsDescription() { + if (!this.showSearchDropdown) { + return ''; // This allows aria-live to see register an update when the dropdown is shown + } + + if (this.showDefaultItems) { + return sprintf(this.$options.i18n.searchDescribedByDefault, { + count: this.searchOptions.length, + }); + } + + return this.loading + ? this.$options.i18n.searchResultsLoading + : sprintf(this.$options.i18n.searchDescribedByUpdated, { + count: this.searchOptions.length, + }); + }, }, methods: { - ...mapActions(['setSearch', 'fetchAutocompleteOptions']), + ...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']), openDropdown() { this.showDropdown = true; }, @@ -51,44 +116,77 @@ export default { this.showDropdown = false; }, submitSearch() { - return visitUrl(this.searchQuery); + return visitUrl(this.currentFocusedOption?.url || this.searchQuery); }, - getAutocompleteOptions(searchTerm) { + getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) { if (!searchTerm) { - return; + this.clearAutocomplete(); + } else { + this.fetchAutocompleteOptions(); } - - this.fetchAutocompleteOptions(); - }, + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), }, + SEARCH_BOX_INDEX, + SEARCH_INPUT_DESCRIPTION, + SEARCH_RESULTS_DESCRIPTION, }; </script> <template> - <section v-outside="closeDropdown" class="header-search gl-relative"> + <form + v-outside="closeDropdown" + role="search" + :aria-label="$options.i18n.searchAria" + class="header-search gl-relative" + > <gl-search-box-by-type + id="search" v-model="searchText" - :debounce="500" + role="searchbox" + class="gl-z-index-1" autocomplete="off" :placeholder="$options.i18n.searchPlaceholder" + :aria-activedescendant="currentFocusedId" + :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" @focus="openDropdown" @click="openDropdown" @input="getAutocompleteOptions" - @keydown.enter="submitSearch" - @keydown.esc="closeDropdown" + @keydown.enter.stop.prevent="submitSearch" /> + <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">{{ + searchInputDescribeBy + }}</span> + <span + role="region" + :data-testid="$options.SEARCH_RESULTS_DESCRIPTION" + class="gl-sr-only" + aria-live="polite" + aria-atomic="true" + > + {{ dropdownResultsDescription }} + </span> <div v-if="showSearchDropdown" data-testid="header-search-dropdown-menu" class="header-search-dropdown-menu gl-absolute gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0" > <div class="header-search-dropdown-content gl-overflow-y-auto gl-py-2"> - <header-search-default-items v-if="showDefaultItems" /> + <dropdown-keyboard-navigation + v-model="currentFocusIndex" + :max="searchOptions.length - 1" + :min="$options.SEARCH_BOX_INDEX" + :default-index="defaultIndex" + @tab="closeDropdown" + /> + <header-search-default-items + v-if="showDefaultItems" + :current-focused-option="currentFocusedOption" + /> <template v-else> - <header-search-scoped-items /> - <header-search-autocomplete-items /> + <header-search-scoped-items :current-focused-option="currentFocusedOption" /> + <header-search-autocomplete-items :current-focused-option="currentFocusedOption" /> </template> </div> </div> - </section> + </form> </template> diff --git a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue index 9bea2b280f7..9f4f4768247 100644 --- a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue +++ b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue @@ -23,10 +23,26 @@ export default { directives: { SafeHtml, }, + props: { + currentFocusedOption: { + type: Object, + required: false, + default: () => null, + }, + }, computed: { ...mapState(['search', 'loading']), ...mapGetters(['autocompleteGroupedSearchOptions']), }, + watch: { + currentFocusedOption() { + const focusedElement = this.$refs[this.currentFocusedOption?.html_id]?.[0]?.$el; + + if (focusedElement) { + focusedElement.scrollIntoView(false); + } + }, + }, methods: { highlightedName(val) { return highlight(val, this.search); @@ -38,6 +54,9 @@ export default { return SMALL_AVATAR_PX; }, + isOptionFocused(data) { + return this.currentFocusedOption?.html_id === data.html_id; + }, }, }; </script> @@ -49,13 +68,17 @@ export default { <gl-dropdown-divider /> <gl-dropdown-section-header>{{ option.category }}</gl-dropdown-section-header> <gl-dropdown-item - v-for="(data, index) in option.data" - :id="`autocomplete-${option.category}-${index}`" - :key="index" + v-for="data in option.data" + :id="data.html_id" + :ref="data.html_id" + :key="data.html_id" + :class="{ 'gl-bg-gray-50': isOptionFocused(data) }" + :aria-selected="isOptionFocused(data)" + :aria-label="data.label" tabindex="-1" :href="data.url" > - <div class="gl-display-flex gl-align-items-center"> + <div class="gl-display-flex gl-align-items-center" aria-hidden="true"> <gl-avatar v-if="data.avatar_url !== undefined" :src="data.avatar_url" diff --git a/app/assets/javascripts/header_search/components/header_search_default_items.vue b/app/assets/javascripts/header_search/components/header_search_default_items.vue index 2871937ed3a..53e63bc6cca 100644 --- a/app/assets/javascripts/header_search/components/header_search_default_items.vue +++ b/app/assets/javascripts/header_search/components/header_search_default_items.vue @@ -12,6 +12,13 @@ export default { GlDropdownSectionHeader, GlDropdownItem, }, + props: { + currentFocusedOption: { + type: Object, + required: false, + default: () => null, + }, + }, computed: { ...mapState(['searchContext']), ...mapGetters(['defaultSearchOptions']), @@ -23,6 +30,11 @@ export default { ); }, }, + methods: { + isOptionFocused(option) { + return this.currentFocusedOption?.html_id === option.html_id; + }, + }, }; </script> @@ -30,13 +42,17 @@ export default { <div> <gl-dropdown-section-header>{{ sectionHeader }}</gl-dropdown-section-header> <gl-dropdown-item - v-for="(option, index) in defaultSearchOptions" - :id="`default-${index}`" - :key="index" + v-for="option in defaultSearchOptions" + :id="option.html_id" + :ref="option.html_id" + :key="option.html_id" + :class="{ 'gl-bg-gray-50': isOptionFocused(option) }" + :aria-selected="isOptionFocused(option)" + :aria-label="option.title" tabindex="-1" :href="option.url" > - {{ option.title }} + <span aria-hidden="true">{{ option.title }}</span> </gl-dropdown-item> </div> </template> diff --git a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue index 645eba05148..3aebee71509 100644 --- a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue +++ b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue @@ -1,31 +1,57 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; +import { __, sprintf } from '~/locale'; export default { name: 'HeaderSearchScopedItems', components: { GlDropdownItem, }, + props: { + currentFocusedOption: { + type: Object, + required: false, + default: () => null, + }, + }, computed: { ...mapState(['search']), ...mapGetters(['scopedSearchOptions']), }, + methods: { + isOptionFocused(option) { + return this.currentFocusedOption?.html_id === option.html_id; + }, + ariaLabel(option) { + return sprintf(__('%{search} %{description} %{scope}'), { + search: this.search, + description: option.description, + scope: option.scope || '', + }); + }, + }, }; </script> <template> <div> <gl-dropdown-item - v-for="(option, index) in scopedSearchOptions" - :id="`scoped-${index}`" - :key="index" + v-for="option in scopedSearchOptions" + :id="option.html_id" + :ref="option.html_id" + :key="option.html_id" + :class="{ 'gl-bg-gray-50': isOptionFocused(option) }" + :aria-selected="isOptionFocused(option)" + :aria-label="ariaLabel(option)" tabindex="-1" :href="option.url" > - "<span class="gl-font-weight-bold">{{ search }}</span - >" {{ option.description }} - <span v-if="option.scope" class="gl-font-style-italic">{{ option.scope }}</span> + <span aria-hidden="true"> + "<span class="gl-font-weight-bold">{{ search }}</span + >" {{ option.description }} + <span v-if="option.scope" class="gl-font-style-italic">{{ option.scope }}</span> + </span> </gl-dropdown-item> </div> </template> diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js index 2fadb1bd1ee..b2e45fcd648 100644 --- a/app/assets/javascripts/header_search/constants.js +++ b/app/assets/javascripts/header_search/constants.js @@ -1,20 +1,20 @@ -import { __ } from '~/locale'; +import { s__ } from '~/locale'; -export const MSG_ISSUES_ASSIGNED_TO_ME = __('Issues assigned to me'); +export const MSG_ISSUES_ASSIGNED_TO_ME = s__('GlobalSearch|Issues assigned to me'); -export const MSG_ISSUES_IVE_CREATED = __("Issues I've created"); +export const MSG_ISSUES_IVE_CREATED = s__("GlobalSearch|Issues I've created"); -export const MSG_MR_ASSIGNED_TO_ME = __('Merge requests assigned to me'); +export const MSG_MR_ASSIGNED_TO_ME = s__('GlobalSearch|Merge requests assigned to me'); -export const MSG_MR_IM_REVIEWER = __("Merge requests that I'm a reviewer"); +export const MSG_MR_IM_REVIEWER = s__("GlobalSearch|Merge requests that I'm a reviewer"); -export const MSG_MR_IVE_CREATED = __("Merge requests I've created"); +export const MSG_MR_IVE_CREATED = s__("GlobalSearch|Merge requests I've created"); -export const MSG_IN_ALL_GITLAB = __('in all GitLab'); +export const MSG_IN_ALL_GITLAB = s__('GlobalSearch|in all GitLab'); -export const MSG_IN_GROUP = __('in group'); +export const MSG_IN_GROUP = s__('GlobalSearch|in group'); -export const MSG_IN_PROJECT = __('in project'); +export const MSG_IN_PROJECT = s__('GlobalSearch|in project'); export const GROUPS_CATEGORY = 'Groups'; @@ -23,3 +23,11 @@ export const PROJECTS_CATEGORY = 'Projects'; export const LARGE_AVATAR_PX = 32; export const SMALL_AVATAR_PX = 16; + +export const FIRST_DROPDOWN_INDEX = 0; + +export const SEARCH_BOX_INDEX = -1; + +export const SEARCH_INPUT_DESCRIPTION = 'search-input-description'; + +export const SEARCH_RESULTS_DESCRIPTION = 'search-results-description'; diff --git a/app/assets/javascripts/header_search/store/actions.js b/app/assets/javascripts/header_search/store/actions.js index 2c3b1bd4c0f..0ba956f3ed1 100644 --- a/app/assets/javascripts/header_search/store/actions.js +++ b/app/assets/javascripts/header_search/store/actions.js @@ -14,6 +14,10 @@ export const fetchAutocompleteOptions = ({ commit, getters }) => { }); }; +export const clearAutocomplete = ({ commit }) => { + commit(types.CLEAR_AUTOCOMPLETE); +}; + export const setSearch = ({ commit }, value) => { commit(types.SET_SEARCH, value); }; diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js index 3f4e231ca55..a1348a8aa3f 100644 --- a/app/assets/javascripts/header_search/store/getters.js +++ b/app/assets/javascripts/header_search/store/getters.js @@ -1,3 +1,4 @@ +import { omitBy, isNil } from 'lodash'; import { objectToQuery } from '~/lib/utils/url_utility'; import { @@ -12,23 +13,29 @@ import { } from '../constants'; export const searchQuery = (state) => { - const query = { - search: state.search, - nav_source: 'navbar', - project_id: state.searchContext.project?.id, - group_id: state.searchContext.group?.id, - scope: state.searchContext.scope, - }; + const query = omitBy( + { + search: state.search, + nav_source: 'navbar', + project_id: state.searchContext.project?.id, + group_id: state.searchContext.group?.id, + scope: state.searchContext?.scope, + }, + isNil, + ); return `${state.searchPath}?${objectToQuery(query)}`; }; export const autocompleteQuery = (state) => { - const query = { - term: state.search, - project_id: state.searchContext.project?.id, - project_ref: state.searchContext.ref, - }; + const query = omitBy( + { + term: state.search, + project_id: state.searchContext.project?.id, + project_ref: state.searchContext?.ref, + }, + isNil, + ); return `${state.autocompletePath}?${objectToQuery(query)}`; }; @@ -54,22 +61,27 @@ export const defaultSearchOptions = (state, getters) => { return [ { + html_id: 'default-issues-assigned', title: MSG_ISSUES_ASSIGNED_TO_ME, url: `${getters.scopedIssuesPath}/?assignee_username=${userName}`, }, { + html_id: 'default-issues-created', title: MSG_ISSUES_IVE_CREATED, url: `${getters.scopedIssuesPath}/?author_username=${userName}`, }, { + html_id: 'default-mrs-assigned', title: MSG_MR_ASSIGNED_TO_ME, url: `${getters.scopedMRPath}/?assignee_username=${userName}`, }, { + html_id: 'default-mrs-reviewer', title: MSG_MR_IM_REVIEWER, url: `${getters.scopedMRPath}/?reviewer_username=${userName}`, }, { + html_id: 'default-mrs-created', title: MSG_MR_IVE_CREATED, url: `${getters.scopedMRPath}/?author_username=${userName}`, }, @@ -77,42 +89,43 @@ export const defaultSearchOptions = (state, getters) => { }; export const projectUrl = (state) => { - if (!state.searchContext.project || !state.searchContext.group) { - return null; - } - - const query = { - search: state.search, - nav_source: 'navbar', - project_id: state.searchContext.project.id, - group_id: state.searchContext.group.id, - scope: state.searchContext.scope, - }; + const query = omitBy( + { + search: state.search, + nav_source: 'navbar', + project_id: state.searchContext?.project?.id, + group_id: state.searchContext?.group?.id, + scope: state.searchContext?.scope, + }, + isNil, + ); return `${state.searchPath}?${objectToQuery(query)}`; }; export const groupUrl = (state) => { - if (!state.searchContext.group) { - return null; - } - - const query = { - search: state.search, - nav_source: 'navbar', - group_id: state.searchContext.group.id, - scope: state.searchContext.scope, - }; + const query = omitBy( + { + search: state.search, + nav_source: 'navbar', + group_id: state.searchContext?.group?.id, + scope: state.searchContext?.scope, + }, + isNil, + ); return `${state.searchPath}?${objectToQuery(query)}`; }; export const allUrl = (state) => { - const query = { - search: state.search, - nav_source: 'navbar', - scope: state.searchContext.scope, - }; + const query = omitBy( + { + search: state.search, + nav_source: 'navbar', + scope: state.searchContext?.scope, + }, + isNil, + ); return `${state.searchPath}?${objectToQuery(query)}`; }; @@ -122,6 +135,7 @@ export const scopedSearchOptions = (state, getters) => { if (state.searchContext.project) { options.push({ + html_id: 'scoped-in-project', scope: state.searchContext.project.name, description: MSG_IN_PROJECT, url: getters.projectUrl, @@ -130,6 +144,7 @@ export const scopedSearchOptions = (state, getters) => { if (state.searchContext.group) { options.push({ + html_id: 'scoped-in-group', scope: state.searchContext.group.name, description: MSG_IN_GROUP, url: getters.groupUrl, @@ -137,6 +152,7 @@ export const scopedSearchOptions = (state, getters) => { } options.push({ + html_id: 'scoped-in-all', description: MSG_IN_ALL_GITLAB, url: getters.allUrl, }); @@ -165,3 +181,18 @@ export const autocompleteGroupedSearchOptions = (state) => { return results; }; + +export const searchOptions = (state, getters) => { + if (!state.search) { + return getters.defaultSearchOptions; + } + + const sortedAutocompleteOptions = Object.values(getters.autocompleteGroupedSearchOptions).reduce( + (options, group) => { + return [...options, ...group.data]; + }, + [], + ); + + return getters.scopedSearchOptions.concat(sortedAutocompleteOptions); +}; diff --git a/app/assets/javascripts/header_search/store/mutation_types.js b/app/assets/javascripts/header_search/store/mutation_types.js index a2358621ce6..6e65345757f 100644 --- a/app/assets/javascripts/header_search/store/mutation_types.js +++ b/app/assets/javascripts/header_search/store/mutation_types.js @@ -1,5 +1,6 @@ export const REQUEST_AUTOCOMPLETE = 'REQUEST_AUTOCOMPLETE'; export const RECEIVE_AUTOCOMPLETE_SUCCESS = 'RECEIVE_AUTOCOMPLETE_SUCCESS'; export const RECEIVE_AUTOCOMPLETE_ERROR = 'RECEIVE_AUTOCOMPLETE_ERROR'; +export const CLEAR_AUTOCOMPLETE = 'CLEAR_AUTOCOMPLETE'; export const SET_SEARCH = 'SET_SEARCH'; diff --git a/app/assets/javascripts/header_search/store/mutations.js b/app/assets/javascripts/header_search/store/mutations.js index 175b5406540..26b4a8854fe 100644 --- a/app/assets/javascripts/header_search/store/mutations.js +++ b/app/assets/javascripts/header_search/store/mutations.js @@ -7,12 +7,17 @@ export default { }, [types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) { state.loading = false; - state.autocompleteOptions = data; + state.autocompleteOptions = data.map((d, i) => { + return { html_id: `autocomplete-${d.category}-${i}`, ...d }; + }); }, [types.RECEIVE_AUTOCOMPLETE_ERROR](state) { state.loading = false; state.autocompleteOptions = []; }, + [types.CLEAR_AUTOCOMPLETE](state) { + state.autocompleteOptions = []; + }, [types.SET_SEARCH](state, value) { state.search = value; }, diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue index c71d911adfb..846b4d92724 100644 --- a/app/assets/javascripts/ide/components/activity_bar.vue +++ b/app/assets/javascripts/ide/components/activity_bar.vue @@ -63,7 +63,7 @@ export default { class="ide-sidebar-link js-ide-review-mode" @click.prevent="changedActivityView($event, $options.leftSidebarViews.review.name)" > - <gl-icon name="file-modified" /> + <gl-icon name="review-list" /> </button> </li> <li> diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue index b987adc8bae..0fc7337ad26 100644 --- a/app/assets/javascripts/ide/components/ide_tree_list.vue +++ b/app/assets/javascripts/ide/components/ide_tree_list.vue @@ -29,14 +29,20 @@ export default { }, }, watch: { - showLoading(newVal) { - if (!newVal) { - this.$emit('tree-ready'); - } + showLoading() { + this.notifyTreeReady(); }, }, + mounted() { + this.notifyTreeReady(); + }, methods: { ...mapActions(['toggleTreeOpen']), + notifyTreeReady() { + if (!this.showLoading) { + this.$emit('tree-ready'); + } + }, clickedFile() { performanceMarkAndMeasure({ mark: WEBIDE_MARK_FILE_CLICKED }); }, diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue index bdd201aac1b..87b60eca73c 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/index.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -67,7 +67,7 @@ export default { data-qa-selector="dropdown_button" @click.stop="openDropdown()" > - <gl-icon name="ellipsis_v" /> <gl-icon name="chevron-down" /> + <gl-icon name="ellipsis_v" /> </button> <ul ref="dropdownMenu" class="dropdown-menu dropdown-menu-right"> <template v-if="type === 'tree'"> diff --git a/app/assets/javascripts/ide/components/pipelines/empty_state.vue b/app/assets/javascripts/ide/components/pipelines/empty_state.vue new file mode 100644 index 00000000000..194deb2ece0 --- /dev/null +++ b/app/assets/javascripts/ide/components/pipelines/empty_state.vue @@ -0,0 +1,35 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { mapState } from 'vuex'; +import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +export default { + components: { + GlEmptyState, + }, + computed: { + ...mapState(['pipelinesEmptyStateSvgPath']), + ciHelpPagePath() { + return helpPagePath('ci/quick_start/index.md'); + }, + }, + i18n: { + title: s__('Pipelines|Build with confidence'), + description: s__(`Pipelines|GitLab CI/CD can automatically build, + test, and deploy your code. Let GitLab take care of time + consuming tasks, so you can spend more time creating.`), + primaryButtonText: s__('Pipelines|Get started with GitLab CI/CD'), + }, +}; +</script> + +<template> + <gl-empty-state + :title="$options.i18n.title" + :svg-path="pipelinesEmptyStateSvgPath" + :description="$options.i18n.description" + :primary-button-text="$options.i18n.primaryButtonText" + :primary-button-link="ciHelpPagePath" + /> +</template> diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index e1caf1ba44a..7f513afe82e 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -11,10 +11,17 @@ import { import { escape } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; import IDEServices from '~/ide/services'; -import { sprintf, __ } from '../../../locale'; -import EmptyState from '../../../pipelines/components/pipelines_list/empty_state.vue'; -import CiIcon from '../../../vue_shared/components/ci_icon.vue'; +import { sprintf, __ } from '~/locale'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import JobsList from '../jobs/list.vue'; +import EmptyState from './empty_state.vue'; + +const CLASSES_FLEX_VERTICAL_CENTER = [ + 'gl-h-full', + 'gl-display-flex', + 'gl-flex-direction-column', + 'gl-justify-content-center', +]; export default { components: { @@ -32,7 +39,6 @@ export default { SafeHtml, }, computed: { - ...mapState(['pipelinesEmptyStateSvgPath']), ...mapGetters(['currentProject']), ...mapGetters('pipelines', ['jobsCount', 'failedJobsCount', 'failedStages', 'pipelineFailed']), ...mapState('pipelines', [ @@ -63,12 +69,15 @@ export default { methods: { ...mapActions('pipelines', ['fetchLatestPipeline']), }, + CLASSES_FLEX_VERTICAL_CENTER, }; </script> <template> <div class="ide-pipeline"> - <gl-loading-icon v-if="showLoadingIcon" size="lg" class="gl-mt-3" /> + <div v-if="showLoadingIcon" :class="$options.CLASSES_FLEX_VERTICAL_CENTER"> + <gl-loading-icon size="lg" /> + </div> <template v-else-if="hasLoadedPipeline"> <header v-if="latestPipeline" class="ide-tree-header ide-pipeline-header"> <ci-icon :status="latestPipeline.details.status" :size="24" class="d-flex" /> @@ -83,12 +92,9 @@ export default { </a> </span> </header> - <empty-state - v-if="!latestPipeline" - :empty-state-svg-path="pipelinesEmptyStateSvgPath" - :can-set-ci="true" - class="gl-p-5" - /> + <div v-if="!latestPipeline" :class="$options.CLASSES_FLEX_VERTICAL_CENTER"> + <empty-state /> + </div> <gl-alert v-else-if="latestPipeline.yamlError" variant="danger" diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 2bf99550bf2..05493db1dff 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -7,6 +7,7 @@ import { EDITOR_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN, } from '~/editor/constants'; +import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext'; import SourceEditor from '~/editor/source_editor'; import createFlash from '~/flash'; @@ -302,30 +303,32 @@ export default { ...instanceOptions, ...this.editorOptions, }); - - this.editor.use( - new EditorWebIdeExtension({ - instance: this.editor, - modelManager: this.modelManager, - store: this.$store, - file: this.file, - options: this.editorOptions, - }), - ); + this.editor.use([ + { + definition: SourceEditorExtension, + }, + { + definition: EditorWebIdeExtension, + setupOptions: { + modelManager: this.modelManager, + store: this.$store, + file: this.file, + options: this.editorOptions, + }, + }, + ]); if ( this.fileType === MARKDOWN_FILE_TYPE && this.editor?.getEditorType() === EDITOR_TYPE_CODE && this.previewMarkdownPath ) { - import('~/editor/extensions/source_editor_markdown_ext') - .then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => { - this.editor.use( - new MarkdownExtension({ - instance: this.editor, - previewMarkdownPath: this.previewMarkdownPath, - }), - ); + import('~/editor/extensions/source_editor_markdown_livepreview_ext') + .then(({ EditorMarkdownPreviewExtension: MarkdownLivePreview }) => { + this.editor.use({ + definition: MarkdownLivePreview, + setupOptions: { previewMarkdownPath: this.previewMarkdownPath }, + }); }) .catch((e) => createFlash({ diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 706d98fdb90..775b6906498 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -76,15 +76,15 @@ export const stageKeys = { export const commitItemIconMap = { addition: { icon: 'file-addition', - class: 'ide-file-addition', + class: 'file-addition ide-file-addition', }, modified: { icon: 'file-modified', - class: 'ide-file-modified', + class: 'file-modified ide-file-modified', }, deleted: { icon: 'file-deletion', - class: 'ide-file-deletion', + class: 'file-deletion ide-file-deletion', }, }; diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js index 27cedd80347..1fc447886bb 100644 --- a/app/assets/javascripts/ide/ide_router.js +++ b/app/assets/javascripts/ide/ide_router.js @@ -1,8 +1,6 @@ import Vue from 'vue'; -import createFlash from '~/flash'; import IdeRouter from '~/ide/ide_router_extension'; import { joinPaths } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; import { WEBIDE_MARK_FETCH_PROJECT_DATA_START, WEBIDE_MARK_FETCH_PROJECT_DATA_FINISH, @@ -75,49 +73,34 @@ export const createRouter = (store, defaultBranch) => { router.beforeEach((to, from, next) => { if (to.params.namespace && to.params.project) { - performanceMarkAndMeasure({ mark: WEBIDE_MARK_FETCH_PROJECT_DATA_START }); - store - .dispatch('getProjectData', { - namespace: to.params.namespace, - projectId: to.params.project, - }) - .then(() => { - const basePath = to.params.pathMatch || ''; - const projectId = `${to.params.namespace}/${to.params.project}`; - const branchId = to.params.branchid; - const mergeRequestId = to.params.mrid; + const basePath = to.params.pathMatch || ''; + const projectId = `${to.params.namespace}/${to.params.project}`; + const branchId = to.params.branchid; + const mergeRequestId = to.params.mrid; - if (branchId) { - performanceMarkAndMeasure({ - mark: WEBIDE_MARK_FETCH_PROJECT_DATA_FINISH, - measures: [ - { - name: WEBIDE_MEASURE_FETCH_PROJECT_DATA, - start: WEBIDE_MARK_FETCH_PROJECT_DATA_START, - }, - ], - }); - store.dispatch('openBranch', { - projectId, - branchId, - basePath, - }); - } else if (mergeRequestId) { - store.dispatch('openMergeRequest', { - projectId, - mergeRequestId, - targetProjectId: to.query.target_project, - }); - } - }) - .catch((e) => { - createFlash({ - message: __('Error while loading the project data. Please try again.'), - fadeTransition: false, - addBodyClass: true, - }); - throw e; + performanceMarkAndMeasure({ mark: WEBIDE_MARK_FETCH_PROJECT_DATA_START }); + if (branchId) { + performanceMarkAndMeasure({ + mark: WEBIDE_MARK_FETCH_PROJECT_DATA_FINISH, + measures: [ + { + name: WEBIDE_MEASURE_FETCH_PROJECT_DATA, + start: WEBIDE_MARK_FETCH_PROJECT_DATA_START, + }, + ], + }); + store.dispatch('openBranch', { + projectId, + branchId, + basePath, + }); + } else if (mergeRequestId) { + store.dispatch('openMergeRequest', { + projectId, + mergeRequestId, + targetProjectId: to.query.target_project, }); + } } next(); diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index bdffed70882..df643675357 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -34,11 +34,18 @@ Vue.use(PerformancePlugin, { * @param {extendStoreCallback} options.extendStore - * Function that receives the default store and returns an extended one. */ -export function initIde(el, options = {}) { +export const initIde = (el, options = {}) => { if (!el) return null; const { rootComponent = ide, extendStore = identity } = options; + const store = createStore(); + const project = JSON.parse(el.dataset.project); + store.dispatch('setProject', { project }); + + // fire and forget fetching non-critical project info + store.dispatch('fetchProjectPermissions'); + const router = createRouter(store, el.dataset.defaultBranch || DEFAULT_BRANCH); return new Vue({ @@ -77,7 +84,7 @@ export function initIde(el, options = {}) { return createElement(rootComponent); }, }); -} +}; /** * Start the IDE. diff --git a/app/assets/javascripts/ide/lib/themes/monokai.js b/app/assets/javascripts/ide/lib/themes/monokai.js index d7636574754..36fa5039be7 100644 --- a/app/assets/javascripts/ide/lib/themes/monokai.js +++ b/app/assets/javascripts/ide/lib/themes/monokai.js @@ -162,8 +162,8 @@ export default { 'editor.selectionBackground': '#49483E', 'editor.lineHighlightBackground': '#3E3D32', 'editorCursor.foreground': '#F8F8F0', - 'editorWhitespace.foreground': '#3B3A32', 'editorIndentGuide.activeBackground': '#9D550FB0', 'editor.selectionHighlightBorder': '#222218', + 'editorWhitespace.foreground': '#75715e', }, }; diff --git a/app/assets/javascripts/ide/lib/themes/none.js b/app/assets/javascripts/ide/lib/themes/none.js index 8e722c4ff88..0842bc04cff 100644 --- a/app/assets/javascripts/ide/lib/themes/none.js +++ b/app/assets/javascripts/ide/lib/themes/none.js @@ -13,5 +13,6 @@ export default { 'diffEditor.insertedTextBackground': '#a0f5b420', 'diffEditor.removedTextBackground': '#f9d7dc20', 'editorIndentGuide.activeBackground': '#cccccc', + 'editorSuggestWidget.focusHighlightForeground': '#96D8FD', }, }; diff --git a/app/assets/javascripts/ide/lib/themes/solarized_dark.js b/app/assets/javascripts/ide/lib/themes/solarized_dark.js index 3c9414b9dc9..8ae609285ac 100644 --- a/app/assets/javascripts/ide/lib/themes/solarized_dark.js +++ b/app/assets/javascripts/ide/lib/themes/solarized_dark.js @@ -1105,6 +1105,6 @@ export default { 'editor.selectionBackground': '#073642', 'editor.lineHighlightBackground': '#073642', 'editorCursor.foreground': '#819090', - 'editorWhitespace.foreground': '#073642', + 'editorWhitespace.foreground': '#586e75', }, }; diff --git a/app/assets/javascripts/ide/lib/themes/solarized_light.js b/app/assets/javascripts/ide/lib/themes/solarized_light.js index b7bfcf33b0f..2c9f3d904f1 100644 --- a/app/assets/javascripts/ide/lib/themes/solarized_light.js +++ b/app/assets/javascripts/ide/lib/themes/solarized_light.js @@ -1096,6 +1096,6 @@ export default { 'editor.selectionBackground': '#EEE8D5', 'editor.lineHighlightBackground': '#EEE8D5', 'editorCursor.foreground': '#000000', - 'editorWhitespace.foreground': '#EAE3C9', + 'editorWhitespace.foreground': '#93a1a1', }, }; diff --git a/app/assets/javascripts/ide/lib/themes/white.js b/app/assets/javascripts/ide/lib/themes/white.js index f06458d8a16..69c63c82021 100644 --- a/app/assets/javascripts/ide/lib/themes/white.js +++ b/app/assets/javascripts/ide/lib/themes/white.js @@ -142,5 +142,6 @@ export default { 'diffEditor.insertedTextBackground': '#a0f5b420', 'diffEditor.removedTextBackground': '#f9d7dc20', 'editorIndentGuide.activeBackground': '#cccccc', + 'editorSuggestWidget.focusHighlightForeground': '#96D8FD', }, }; diff --git a/app/assets/javascripts/ide/queries/ide_project.fragment.graphql b/app/assets/javascripts/ide/queries/ide_project.fragment.graphql index c107f2376f9..a0b520858e6 100644 --- a/app/assets/javascripts/ide/queries/ide_project.fragment.graphql +++ b/app/assets/javascripts/ide/queries/ide_project.fragment.graphql @@ -1,4 +1,5 @@ fragment IdeProject on Project { + id userPermissions { createMergeRequestIn readMergeRequest diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index ef4f47f226a..805476c71bc 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -1,19 +1,12 @@ -import getIdeProject from 'ee_else_ce/ide/queries/get_ide_project.query.graphql'; import Api from '~/api'; +import getIdeProject from 'ee_else_ce/ide/queries/get_ide_project.query.graphql'; import dismissUserCallout from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import axios from '~/lib/utils/axios_utils'; import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; -import ciConfig from '~/pipeline_editor/graphql/queries/ci_config.graphql'; +import ciConfig from '~/pipeline_editor/graphql/queries/ci_config.query.graphql'; import { query, mutate } from './gql'; -const fetchApiProjectData = (projectPath) => Api.project(projectPath).then(({ data }) => data); - -const fetchGqlProjectData = (projectPath) => - query({ - query: getIdeProject, - variables: { projectPath }, - }).then(({ data }) => data.project); - export default { getFileData(endpoint) { return axios.get(endpoint, { @@ -61,18 +54,6 @@ export default { ) .then(({ data }) => data); }, - getProjectData(namespace, project) { - const projectPath = `${namespace}/${project}`; - - return Promise.all([fetchApiProjectData(projectPath), fetchGqlProjectData(projectPath)]).then( - ([apiProjectData, gqlProjectData]) => ({ - data: { - ...apiProjectData, - ...gqlProjectData, - }, - }), - ); - }, getProjectMergeRequests(projectId, params = {}) { return Api.projectMergeRequests(projectId, params); }, @@ -115,4 +96,13 @@ export default { variables: { input: { featureName: name } }, }).then(({ data }) => data); }, + getProjectPermissionsData(projectPath) { + return query({ + query: getIdeProject, + variables: { projectPath }, + }).then(({ data }) => ({ + ...data.project, + id: getIdFromGraphQLId(data.project.id), + })); + }, }; diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js index 93ad19ba81e..0ec808339fb 100644 --- a/app/assets/javascripts/ide/stores/actions/project.js +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -1,35 +1,44 @@ import { escape } from 'lodash'; import createFlash from '~/flash'; import { __, sprintf } from '~/locale'; +import { logError } from '~/lib/logger'; import api from '../../../api'; import service from '../../services'; import * as types from '../mutation_types'; -export const getProjectData = ({ commit, state }, { namespace, projectId, force = false } = {}) => - new Promise((resolve, reject) => { - if (!state.projects[`${namespace}/${projectId}`] || force) { - commit(types.TOGGLE_LOADING, { entry: state }); - service - .getProjectData(namespace, projectId) - .then((res) => res.data) - .then((data) => { - commit(types.TOGGLE_LOADING, { entry: state }); - commit(types.SET_PROJECT, { projectPath: `${namespace}/${projectId}`, project: data }); - commit(types.SET_CURRENT_PROJECT, `${namespace}/${projectId}`); - resolve(data); - }) - .catch(() => { - createFlash({ - message: __('Error loading project data. Please try again.'), - fadeTransition: false, - addBodyClass: true, - }); - reject(new Error(`Project not loaded ${namespace}/${projectId}`)); - }); - } else { - resolve(state.projects[`${namespace}/${projectId}`]); - } +const ERROR_LOADING_PROJECT = __('Error loading project data. Please try again.'); + +const errorFetchingData = (e) => { + logError(ERROR_LOADING_PROJECT, e); + + createFlash({ + message: ERROR_LOADING_PROJECT, + fadeTransition: false, + addBodyClass: true, }); +}; + +export const setProject = ({ commit }, { project } = {}) => { + if (!project) { + return; + } + const projectPath = project.path_with_namespace; + commit(types.SET_PROJECT, { projectPath, project }); + commit(types.SET_CURRENT_PROJECT, projectPath); +}; + +export const fetchProjectPermissions = ({ commit, state }) => { + const projectPath = state.currentProjectId; + if (!projectPath) { + return undefined; + } + return service + .getProjectPermissionsData(projectPath) + .then((permissions) => { + commit(types.UPDATE_PROJECT, { projectPath, props: permissions }); + }) + .catch(errorFetchingData); +}; export const refreshLastCommitData = ({ commit }, { projectId, branchId } = {}) => service diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index 77755b179ef..13f338c4a48 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -8,6 +8,7 @@ export const SET_LINKS = 'SET_LINKS'; // Project Mutation Types export const SET_PROJECT = 'SET_PROJECT'; export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT'; +export const UPDATE_PROJECT = 'UPDATE_PROJECT'; export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE'; // Merge request mutation types diff --git a/app/assets/javascripts/ide/stores/mutations/project.js b/app/assets/javascripts/ide/stores/mutations/project.js index 034fdad4305..9f65d3a543e 100644 --- a/app/assets/javascripts/ide/stores/mutations/project.js +++ b/app/assets/javascripts/ide/stores/mutations/project.js @@ -1,3 +1,4 @@ +import Vue from 'vue'; import * as types from '../mutation_types'; export default { @@ -24,4 +25,15 @@ export default { empty_repo: value, }); }, + [types.UPDATE_PROJECT](state, { projectPath, props }) { + const project = state.projects[projectPath]; + + if (!project || !props) { + return; + } + + Object.keys(props).forEach((key) => { + Vue.set(project, key, props[key]); + }); + }, }; diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue index e004bc35087..deaf2654424 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue @@ -44,7 +44,7 @@ export default { :size="16" name="information-o" :title=" - s__('BulkImports|Re-import creates a new group. It does not sync with the existing group.') + s__('BulkImport|Re-import creates a new group. It does not sync with the existing group.') " class="gl-ml-3" /> diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue index ec6025c84bb..028197ec9b1 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue @@ -1,9 +1,8 @@ <script> import { + GlAlert, GlButton, GlEmptyState, - GlDropdown, - GlDropdownItem, GlIcon, GlLink, GlLoadingIcon, @@ -14,8 +13,8 @@ import { } from '@gitlab/ui'; import { debounce } from 'lodash'; import createFlash from '~/flash'; -import { s__, __, n__ } from '~/locale'; -import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; +import { s__, __, n__, sprintf } from '~/locale'; +import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; import { getGroupPathAvailability } from '~/rest_api'; import axios from '~/lib/utils/axios_utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; @@ -42,10 +41,9 @@ const DEFAULT_TD_CLASSES = 'gl-vertical-align-top!'; export default { components: { + GlAlert, GlButton, GlEmptyState, - GlDropdown, - GlDropdownItem, GlIcon, GlLink, GlLoadingIcon, @@ -57,7 +55,7 @@ export default { ImportTargetCell, ImportStatusCell, ImportActionsCell, - PaginationLinks, + PaginationBar, }, props: { @@ -83,6 +81,7 @@ export default { selectedGroupsIds: [], pendingGroupsIds: [], importTargets: {}, + unavailableFeaturesAlertVisible: true, }; }, @@ -170,7 +169,7 @@ export default { }, availableGroupsForImport() { - return this.groupsTableData.filter((g) => g.flags.isAvailableForImport && g.flags.isInvalid); + return this.groupsTableData.filter((g) => g.flags.isAvailableForImport && !g.flags.isInvalid); }, humanizedTotal() { @@ -204,6 +203,23 @@ export default { return { start, end, total }; }, + + unavailableFeatures() { + if (!this.hasGroups) { + return []; + } + + return Object.entries(this.bulkImportSourceGroups.versionValidation.features) + .filter(([, { available }]) => available === false) + .map(([k, v]) => ({ title: i18n.features[k] || k, version: v.minVersion })); + }, + + unavailableFeaturesAlertTitle() { + return sprintf(s__('BulkImport| %{host} is running outdated GitLab version (v%{version})'), { + host: this.sourceUrl, + version: this.bulkImportSourceGroups.versionValidation.features.sourceInstanceVersion, + }); + }, }, watch: { @@ -314,9 +330,8 @@ export default { variables: { importRequests }, }); } catch (error) { - const message = error?.networkError?.response?.data?.error ?? i18n.ERROR_IMPORT; createFlash({ - message, + message: i18n.ERROR_IMPORT, captureError: true, error, }); @@ -476,6 +491,38 @@ export default { <img :src="$options.gitlabLogo" class="gl-w-6 gl-h-6 gl-mb-2 gl-display-inline gl-mr-2" /> {{ s__('BulkImport|Import groups from GitLab') }} </h1> + <gl-alert + v-if="unavailableFeatures.length > 0 && unavailableFeaturesAlertVisible" + variant="warning" + :title="unavailableFeaturesAlertTitle" + @dismiss="unavailableFeaturesAlertVisible = false" + > + <gl-sprintf + :message=" + s__( + 'BulkImport|Following data will not be migrated: %{bullets} Contact system administrator of %{host} to upgrade GitLab if you need this data in your migration', + ) + " + > + <template #host> + <gl-link :href="sourceUrl" target="_blank"> + {{ sourceUrl }}<gl-icon name="external-link" class="vertical-align-middle" /> + </gl-link> + </template> + <template #bullets> + <ul> + <li v-for="feature in unavailableFeatures" :key="feature.title"> + <gl-sprintf :message="s__('BulkImport|%{feature} (require v%{version})')"> + <template #feature>{{ feature.title }}</template> + <template #version> + <strong>{{ feature.version }}</strong> + </template> + </gl-sprintf> + </li> + </ul> + </template> + </gl-sprintf> + </gl-alert> <div class="gl-py-5 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex" > @@ -495,7 +542,7 @@ export default { </template> <template #link> <gl-link :href="sourceUrl" target="_blank"> - {{ sourceUrl }} <gl-icon name="external-link" class="vertical-align-middle" /> + {{ sourceUrl }}<gl-icon name="external-link" class="vertical-align-middle" /> </gl-link> </template> </gl-sprintf> @@ -521,13 +568,15 @@ export default { /> <template v-else> <div - class="gl-bg-gray-10 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-p-4 gl-display-flex gl-align-items-center" + class="gl-bg-gray-10 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-px-4 gl-display-flex gl-align-items-center import-table-bar" > - <gl-sprintf :message="__('%{count} selected')"> - <template #count> - {{ selectedGroupsIds.length }} - </template> - </gl-sprintf> + <span data-test-id="selection-count"> + <gl-sprintf :message="__('%{count} selected')"> + <template #count> + {{ selectedGroupsIds.length }} + </template> + </gl-sprintf> + </span> <gl-button category="primary" variant="confirm" @@ -539,7 +588,7 @@ export default { </div> <gl-table ref="table" - class="gl-w-full" + class="gl-w-full import-table" data-qa-selector="import_table" :tbody-tr-class="rowClasses" :tbody-tr-attr="qaRowAttributes" @@ -599,49 +648,13 @@ export default { /> </template> </gl-table> - <div v-if="hasGroups" class="gl-display-flex gl-mt-3 gl-align-items-center"> - <pagination-links - :change="setPage" - :page-info="bulkImportSourceGroups.pageInfo" - class="gl-m-0" - /> - <gl-dropdown category="tertiary" :aria-label="__('Page size')" class="gl-ml-auto"> - <template #button-content> - <span class="font-weight-bold"> - <gl-sprintf :message="__('%{count} items per page')"> - <template #count> - {{ perPage }} - </template> - </gl-sprintf> - </span> - <gl-icon class="gl-button-icon dropdown-chevron" name="chevron-down" /> - </template> - <gl-dropdown-item - v-for="size in $options.PAGE_SIZES" - :key="size" - @click="setPageSize(size)" - > - <gl-sprintf :message="__('%{count} items per page')"> - <template #count> - {{ size }} - </template> - </gl-sprintf> - </gl-dropdown-item> - </gl-dropdown> - <div class="gl-ml-2"> - <gl-sprintf :message="s__('BulkImport|Showing %{start}-%{end} of %{total}')"> - <template #start> - {{ paginationInfo.start }} - </template> - <template #end> - {{ paginationInfo.end }} - </template> - <template #total> - {{ humanizedTotal }} - </template> - </gl-sprintf> - </div> - </div> + <pagination-bar + v-if="hasGroups" + :page-info="bulkImportSourceGroups.pageInfo" + class="gl-mt-3" + @set-page="setPage" + @set-page-size="setPageSize" + /> </template> </template> </div> diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue index ca9ae9447d0..344a6e45370 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue @@ -32,72 +32,84 @@ export default { fullPath() { return this.group.importTarget.targetNamespace.fullPath || s__('BulkImport|No parent'); }, - invalidNameValidationMessage() { - return getInvalidNameValidationMessage(this.group.importTarget); + validationMessage() { + return ( + this.group.progress?.message || getInvalidNameValidationMessage(this.group.importTarget) + ); + }, + validNameState() { + // bootstrap-vue requires null for "indifferent" state, if we return true + // this will highlight field in green like "passed validation" + return this.group.flags.isInvalid && this.group.flags.isAvailableForImport ? false : null; }, }, }; </script> <template> - <div class="gl-display-flex gl-align-items-stretch"> - <import-group-dropdown - #default="{ namespaces }" - :text="fullPath" - :disabled="!group.flags.isAvailableForImport" - :namespaces="availableNamespaces" - toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" - class="gl-h-7 gl-flex-grow-1" - data-qa-selector="target_namespace_selector_dropdown" - > - <gl-dropdown-item @click="$emit('update-target-namespace', { fullPath: '', id: null })">{{ - s__('BulkImport|No parent') - }}</gl-dropdown-item> - <template v-if="namespaces.length"> - <gl-dropdown-divider /> - <gl-dropdown-section-header> - {{ s__('BulkImport|Existing groups') }} - </gl-dropdown-section-header> - <gl-dropdown-item - v-for="ns in namespaces" - :key="ns.fullPath" - data-qa-selector="target_group_dropdown_item" - :data-qa-group-name="ns.fullPath" - @click="$emit('update-target-namespace', ns)" - > - {{ ns.fullPath }} - </gl-dropdown-item> - </template> - </import-group-dropdown> - <div - class="gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1 gl-bg-gray-10" - :class="{ - 'gl-text-gray-400 gl-border-gray-100': !group.flags.isAvailableForImport, - 'gl-border-gray-200': group.flags.isAvailableForImport, - }" - > - / - </div> - <div class="gl-flex-grow-1"> - <gl-form-input - class="gl-rounded-top-left-none gl-rounded-bottom-left-none" + <div> + <div class="gl-display-flex gl-align-items-stretch"> + <import-group-dropdown + #default="{ namespaces }" + :text="fullPath" + :disabled="!group.flags.isAvailableForImport" + :namespaces="availableNamespaces" + toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" + class="gl-h-7 gl-flex-grow-1" + data-qa-selector="target_namespace_selector_dropdown" + > + <gl-dropdown-item @click="$emit('update-target-namespace', { fullPath: '', id: null })">{{ + s__('BulkImport|No parent') + }}</gl-dropdown-item> + <template v-if="namespaces.length"> + <gl-dropdown-divider /> + <gl-dropdown-section-header> + {{ s__('BulkImport|Existing groups') }} + </gl-dropdown-section-header> + <gl-dropdown-item + v-for="ns in namespaces" + :key="ns.fullPath" + data-qa-selector="target_group_dropdown_item" + :data-qa-group-name="ns.fullPath" + @click="$emit('update-target-namespace', ns)" + > + {{ ns.fullPath }} + </gl-dropdown-item> + </template> + </import-group-dropdown> + <div + class="gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1 gl-bg-gray-10" :class="{ - 'gl-inset-border-1-gray-200!': group.flags.isAvailableForImport, - 'gl-inset-border-1-gray-100!': !group.flags.isAvailableForImport, - 'is-invalid': group.flags.isInvalid && group.flags.isAvailableForImport, + 'gl-text-gray-400 gl-border-gray-100': !group.flags.isAvailableForImport, + 'gl-border-gray-200': group.flags.isAvailableForImport, }" - debounce="500" - :disabled="!group.flags.isAvailableForImport" - :value="group.importTarget.newName" - :aria-label="__('New name')" - @input="$emit('update-new-name', $event)" - /> - <p - v-if="group.flags.isAvailableForImport && group.flags.isInvalid" - class="gl-text-red-500 gl-m-0 gl-mt-2" > - {{ invalidNameValidationMessage }} - </p> + / + </div> + <div class="gl-flex-grow-1"> + <gl-form-input + class="gl-rounded-top-left-none gl-rounded-bottom-left-none" + :class="{ + 'gl-inset-border-1-gray-200!': + group.flags.isAvailableForImport && !group.flags.isInvalid, + 'gl-inset-border-1-gray-100!': + !group.flags.isAvailableForImport && !group.flags.isInvalid, + }" + debounce="500" + :disabled="!group.flags.isAvailableForImport" + :value="group.importTarget.newName" + :aria-label="__('New name')" + :state="validNameState" + @input="$emit('update-new-name', $event)" + /> + </div> + </div> + <div + v-if="group.flags.isAvailableForImport && (group.flags.isInvalid || validationMessage)" + class="gl-text-red-500 gl-m-0 gl-mt-2" + role="alert" + > + {{ validationMessage }} </div> </div> </template> diff --git a/app/assets/javascripts/import_entities/import_groups/constants.js b/app/assets/javascripts/import_entities/import_groups/constants.js index aa9cf3897e6..ac1466238d0 100644 --- a/app/assets/javascripts/import_entities/import_groups/constants.js +++ b/app/assets/javascripts/import_entities/import_groups/constants.js @@ -11,6 +11,10 @@ export const i18n = { ), ERROR_IMPORT: s__('BulkImport|Importing the group failed.'), ERROR_IMPORT_COMPLETED: s__('BulkImport|Import is finished. Pick another name for re-import'), + + features: { + projectMigration: __('projects'), + }, }; export const NEW_NAME_FIELD = 'newName'; diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js index bce6e7bcb1f..36da996ea17 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js +++ b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js @@ -14,6 +14,9 @@ export const clientTypenames = { BulkImportPageInfo: 'ClientBulkImportPageInfo', BulkImportTarget: 'ClientBulkImportTarget', BulkImportProgress: 'ClientBulkImportProgress', + BulkImportVersionValidation: 'ClientBulkImportVersionValidation', + BulkImportVersionValidationFeature: 'ClientBulkImportVersionValidationFeature', + BulkImportVersionValidationFeatures: 'ClientBulkImportVersionValidationFeatures', }; function makeLastImportTarget(data) { @@ -92,6 +95,18 @@ export function createResolvers({ endpoints }) { __typename: clientTypenames.BulkImportPageInfo, ...pagination, }, + versionValidation: { + __typename: clientTypenames.BulkImportVersionValidation, + features: { + __typename: clientTypenames.BulkImportVersionValidationFeatures, + sourceInstanceVersion: data.version_validation.features.source_instance_version, + projectMigration: { + __typename: clientTypenames.BulkImportVersionValidationFeature, + available: data.version_validation.features.project_migration.available, + minVersion: data.version_validation.features.project_migration.min_version, + }, + }, + }, }; return response; }, @@ -142,9 +157,7 @@ export function createResolvers({ endpoints }) { }; }); - const { - data: { id: jobId }, - } = await axios.post(endpoints.createBulkImport, { + const { data: originalResponse } = await axios.post(endpoints.createBulkImport, { bulk_import: importOperations.map((op) => ({ source_type: 'group_entity', source_full_path: op.group.fullPath, @@ -153,15 +166,21 @@ export function createResolvers({ endpoints }) { })), }); - return importOperations.map((op) => { + const responses = Array.isArray(originalResponse) + ? originalResponse + : [{ success: true, id: originalResponse.id }]; + + return importOperations.map((op, idx) => { + const response = responses[idx]; const lastImportTarget = { targetNamespace: op.targetNamespace, newName: op.newName, }; const progress = { - id: jobId, - status: STATUSES.CREATED, + id: response.id || `local-${Date.now()}-${idx}`, + status: response.success ? STATUSES.CREATED : STATUSES.FAILED, + message: response.message || null, }; localStorageCache.set(op.group.webUrl, { progress, lastImportTarget }); diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_progress.fragment.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_progress.fragment.graphql index 2d60bf82d65..33c564f36a8 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_progress.fragment.graphql +++ b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_progress.fragment.graphql @@ -1,4 +1,5 @@ fragment BulkImportSourceGroupProgress on ClientBulkImportProgress { id status + message } diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql index 75215471d0f..39289887b75 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql +++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql @@ -9,6 +9,7 @@ mutation importGroups($importRequests: [ImportGroupInput!]!) { progress { id status + message } } } diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql index 28dfefdf8a7..ace8bffc012 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql +++ b/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql @@ -11,5 +11,14 @@ query bulkImportSourceGroups($page: Int = 1, $perPage: Int = 20, $filter: String total totalPages } + versionValidation { + features { + sourceInstanceVersion + projectMigration { + available + minVersion + } + } + } } } diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js index 09bc7b33692..1aad22f0f3f 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js +++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js @@ -22,7 +22,14 @@ export class LocalStorageCache { loadCacheFromStorage() { try { - return JSON.parse(this.storage.getItem(KEY)) ?? {}; + const storage = JSON.parse(this.storage.getItem(KEY)) ?? {}; + Object.values(storage).forEach((entry) => { + if (entry.progress && !('message' in entry.progress)) { + // eslint-disable-next-line no-param-reassign + entry.progress.message = ''; + } + }); + return storage; } catch { return {}; } diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql index b8dd79a5000..c48e22a7717 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql +++ b/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql @@ -11,11 +11,13 @@ type ClientBulkImportTarget { type ClientBulkImportSourceGroupConnection { nodes: [ClientBulkImportSourceGroup!]! pageInfo: ClientBulkImportPageInfo! + versionValidation: ClientBulkImportVersionValidation! } type ClientBulkImportProgress { id: ID! status: String! + message: String } type ClientBulkImportValidationError { @@ -45,6 +47,20 @@ type ClientBulkImportNamespaceSuggestion { suggestions: [String!]! } +type ClientBulkImportVersionValidation { + features: ClientBulkImportVersionValidationFeatures! +} + +type ClientBulkImportVersionValidationFeatures { + project_migration: ClientBulkImportVersionValidationFeature! + sourceInstanceVersion: String! +} + +type ClientBulkImportVersionValidationFeature { + available: Boolean! + min_version: String! +} + extend type Query { bulkImportSourceGroups( page: Int! diff --git a/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql b/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql index eb2dde14464..faa68d37088 100644 --- a/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql +++ b/app/assets/javascripts/incidents/graphql/fragments/incident_fields.fragment.graphql @@ -1,3 +1,4 @@ +# eslint-disable-next-line @graphql-eslint/require-id-when-available fragment IncidentFields on Issue { severity } diff --git a/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql b/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql index 4e44a506c4f..fda8a65d4a4 100644 --- a/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql +++ b/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql @@ -6,6 +6,7 @@ query getIncidentsCountByStatus( $assigneeUsername: String = "" ) { project(fullPath: $projectPath) { + id issueStatusCounts( search: $searchTerm types: $issueTypes diff --git a/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql b/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql index f97664a3b77..1e18d89b656 100644 --- a/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql +++ b/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql @@ -14,6 +14,7 @@ query getIncidents( $assigneeUsername: String = "" ) { project(fullPath: $projectPath) { + id issues( search: $searchTerm types: $issueTypes @@ -27,18 +28,21 @@ query getIncidents( before: $prevPageCursor ) { nodes { + id iid title createdAt state labels { nodes { + id title color } } assignees { nodes { + id name username avatarUrl diff --git a/app/assets/javascripts/init_confirm_danger.js b/app/assets/javascripts/init_confirm_danger.js index d3d32c8be54..a8833a17467 100644 --- a/app/assets/javascripts/init_confirm_danger.js +++ b/app/assets/javascripts/init_confirm_danger.js @@ -10,6 +10,7 @@ export default () => { removeFormId = null, phrase, buttonText, + buttonClass = '', buttonTestid = null, confirmDangerMessage, disabled = false, @@ -25,6 +26,7 @@ export default () => { props: { phrase, buttonText, + buttonClass, buttonTestid, disabled: parseBoolean(disabled), }, diff --git a/app/assets/javascripts/init_issuable_sidebar.js b/app/assets/javascripts/init_issuable_sidebar.js deleted file mode 100644 index 7a70d893008..00000000000 --- a/app/assets/javascripts/init_issuable_sidebar.js +++ /dev/null @@ -1,16 +0,0 @@ -/* eslint-disable no-new */ - -import { getSidebarOptions } from '~/sidebar/mount_sidebar'; -import IssuableContext from './issuable_context'; -import Sidebar from './right_sidebar'; - -export default () => { - const sidebarOptEl = document.querySelector('.js-sidebar-options'); - - if (!sidebarOptEl) return; - - const sidebarOptions = getSidebarOptions(sidebarOptEl); - - new IssuableContext(sidebarOptions.currentUser); - Sidebar.initialize(); -}; diff --git a/app/assets/javascripts/init_labels.js b/app/assets/javascripts/init_labels.js deleted file mode 100644 index 10bfbf7960c..00000000000 --- a/app/assets/javascripts/init_labels.js +++ /dev/null @@ -1,19 +0,0 @@ -import $ from 'jquery'; -import GroupLabelSubscription from './group_label_subscription'; -import LabelManager from './label_manager'; -import ProjectLabelSubscription from './project_label_subscription'; - -export default () => { - if ($('.prioritized-labels').length) { - new LabelManager(); // eslint-disable-line no-new - } - $('.label-subscription').each((i, el) => { - const $el = $(el); - - if ($el.find('.dropdown-group-label').length) { - new GroupLabelSubscription($el); // eslint-disable-line no-new - } else { - new ProjectLabelSubscription($el); // eslint-disable-line no-new - } - }); -}; diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js index d214ee4ded6..84656bd41bb 100644 --- a/app/assets/javascripts/integrations/constants.js +++ b/app/assets/javascripts/integrations/constants.js @@ -1,9 +1,5 @@ import { s__, __ } from '~/locale'; -export const TEST_INTEGRATION_EVENT = 'testIntegration'; -export const SAVE_INTEGRATION_EVENT = 'saveIntegration'; -export const GET_JIRA_ISSUE_TYPES_EVENT = 'getJiraIssueTypes'; -export const TOGGLE_INTEGRATION_EVENT = 'toggleIntegration'; export const VALIDATE_INTEGRATION_FORM_EVENT = 'validateIntegrationForm'; export const integrationLevels = { diff --git a/app/assets/javascripts/integrations/edit/api.js b/app/assets/javascripts/integrations/edit/api.js new file mode 100644 index 00000000000..7bce5604f9d --- /dev/null +++ b/app/assets/javascripts/integrations/edit/api.js @@ -0,0 +1,9 @@ +import axios from '~/lib/utils/axios_utils'; + +/** + * Test the validity of [integrationFormData]. + * @return Promise<{ issuetypes: []String }> - issuetypes contains valid Jira issue types. + */ +export const testIntegrationSettings = (testPath, integrationFormData) => { + return axios.put(testPath, integrationFormData); +}; diff --git a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue index 9804a9e15f6..5ddf3aeb639 100644 --- a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue +++ b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue @@ -1,8 +1,6 @@ <script> import { GlFormGroup, GlFormCheckbox } from '@gitlab/ui'; import { mapGetters } from 'vuex'; -import { TOGGLE_INTEGRATION_EVENT } from '~/integrations/constants'; -import eventHub from '../event_hub'; export default { name: 'ActiveCheckbox', @@ -20,14 +18,11 @@ export default { }, mounted() { this.activated = this.propsSource.initialActivated; - // Initialize view - this.$nextTick(() => { - this.onChange(this.activated); - }); + this.onChange(this.activated); }, methods: { - onChange(e) { - eventHub.$emit(TOGGLE_INTEGRATION_EVENT, e); + onChange(isChecked) { + this.$emit('toggle-integration-active', isChecked); }, }, }; diff --git a/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue index 89f7e3b7a89..bc6aa231a93 100644 --- a/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue +++ b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue @@ -1,22 +1,17 @@ <script> import { GlModal } from '@gitlab/ui'; -import { mapGetters } from 'vuex'; import { __ } from '~/locale'; export default { components: { GlModal, }, + computed: { - ...mapGetters(['isDisabled']), primaryProps() { return { text: __('Save'), - attributes: [ - { variant: 'confirm' }, - { category: 'primary' }, - { disabled: this.isDisabled }, - ], + attributes: [{ variant: 'confirm' }, { category: 'primary' }], }; }, cancelProps() { diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index ba1aeb28616..e570a468944 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -1,14 +1,17 @@ <script> import { GlButton, GlModalDirective, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import { mapState, mapActions, mapGetters } from 'vuex'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { - TEST_INTEGRATION_EVENT, - SAVE_INTEGRATION_EVENT, + VALIDATE_INTEGRATION_FORM_EVENT, + I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE, + I18N_DEFAULT_ERROR_MESSAGE, + I18N_SUCCESSFUL_CONNECTION_MESSAGE, integrationLevels, } from '~/integrations/constants'; import eventHub from '../event_hub'; - +import { testIntegrationSettings } from '../api'; import ActiveCheckbox from './active_checkbox.vue'; import ConfirmationModal from './confirmation_modal.vue'; import DynamicField from './dynamic_field.vue'; @@ -37,22 +40,26 @@ export default { }, mixins: [glFeatureFlagsMixin()], props: { + formSelector: { + type: String, + required: true, + }, helpHtml: { type: String, required: false, default: '', }, }, + data() { + return { + integrationActive: false, + isTesting: false, + isSaving: false, + }; + }, computed: { - ...mapGetters(['currentKey', 'propsSource', 'isDisabled']), - ...mapState([ - 'defaultState', - 'customState', - 'override', - 'isSaving', - 'isTesting', - 'isResetting', - ]), + ...mapGetters(['currentKey', 'propsSource']), + ...mapState(['defaultState', 'customState', 'override', 'isResetting']), isEditable() { return this.propsSource.editable; }, @@ -65,29 +72,81 @@ export default { this.customState.integrationLevel === integrationLevels.GROUP ); }, - showReset() { + showResetButton() { return this.isInstanceOrGroupLevel && this.propsSource.resetPath; }, + showTestButton() { + return this.propsSource.canTest; + }, + disableButtons() { + return Boolean(this.isSaving || this.isResetting || this.isTesting); + }, + }, + mounted() { + // this form element is defined in Haml + this.form = document.querySelector(this.formSelector); }, methods: { - ...mapActions([ - 'setOverride', - 'setIsSaving', - 'setIsTesting', - 'setIsResetting', - 'fetchResetIntegration', - ]), + ...mapActions(['setOverride', 'fetchResetIntegration', 'requestJiraIssueTypes']), onSaveClick() { - this.setIsSaving(true); - eventHub.$emit(SAVE_INTEGRATION_EVENT); + this.isSaving = true; + + if (this.integrationActive && !this.form.checkValidity()) { + this.isSaving = false; + eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); + return; + } + + this.form.submit(); }, onTestClick() { - this.setIsTesting(true); - eventHub.$emit(TEST_INTEGRATION_EVENT); + this.isTesting = true; + + if (!this.form.checkValidity()) { + eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); + return; + } + + testIntegrationSettings(this.propsSource.testPath, this.getFormData()) + .then(({ data: { error, message = I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE } }) => { + if (error) { + eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); + this.$toast.show(message); + return; + } + + this.$toast.show(I18N_SUCCESSFUL_CONNECTION_MESSAGE); + }) + .catch((error) => { + this.$toast.show(I18N_DEFAULT_ERROR_MESSAGE); + Sentry.captureException(error); + }) + .finally(() => { + this.isTesting = false; + }); }, onResetClick() { this.fetchResetIntegration(); }, + onRequestJiraIssueTypes() { + this.requestJiraIssueTypes(this.getFormData()); + }, + getFormData() { + return new FormData(this.form); + }, + onToggleIntegrationState(integrationActive) { + this.integrationActive = integrationActive; + if (!this.form) { + return; + } + + // If integration will be active, enable form validation. + if (integrationActive) { + this.form.removeAttribute('novalidate'); + } else { + this.form.setAttribute('novalidate', true); + } + }, }, helpHtmlConfig: { ADD_ATTR: ['target'], // allow external links, can be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1427 is implemented @@ -114,7 +173,11 @@ export default { <!-- helpHtml is trusted input --> <div v-if="helpHtml" v-safe-html:[$options.helpHtmlConfig]="helpHtml"></div> - <active-checkbox v-if="propsSource.showActive" :key="`${currentKey}-active-checkbox`" /> + <active-checkbox + v-if="propsSource.showActive" + :key="`${currentKey}-active-checkbox`" + @toggle-integration-active="onToggleIntegrationState" + /> <jira-trigger-fields v-if="isJira" :key="`${currentKey}-jira-trigger-fields`" @@ -135,6 +198,7 @@ export default { v-if="isJira && !isInstanceOrGroupLevel" :key="`${currentKey}-jira-issues-fields`" v-bind="propsSource.jiraIssuesProps" + @request-jira-issue-types="onRequestJiraIssueTypes" /> <div v-if="isEditable" class="footer-block row-content-block"> <template v-if="isInstanceOrGroupLevel"> @@ -143,7 +207,7 @@ export default { category="primary" variant="confirm" :loading="isSaving" - :disabled="isDisabled" + :disabled="disableButtons" data-qa-selector="save_changes_button" > {{ __('Save changes') }} @@ -156,7 +220,8 @@ export default { variant="confirm" type="submit" :loading="isSaving" - :disabled="isDisabled" + :disabled="disableButtons" + data-testid="save-button" data-qa-selector="save_changes_button" @click.prevent="onSaveClick" > @@ -164,24 +229,24 @@ export default { </gl-button> <gl-button - v-if="propsSource.canTest" + v-if="showTestButton" category="secondary" variant="confirm" :loading="isTesting" - :disabled="isDisabled" - :href="propsSource.testPath" + :disabled="disableButtons" + data-testid="test-button" @click.prevent="onTestClick" > {{ __('Test settings') }} </gl-button> - <template v-if="showReset"> + <template v-if="showResetButton"> <gl-button v-gl-modal.confirmResetIntegration category="secondary" variant="confirm" :loading="isResetting" - :disabled="isDisabled" + :disabled="disableButtons" data-testid="reset-button" > {{ __('Reset') }} diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue index 7cbfb35aeaa..99498501f6c 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue @@ -1,10 +1,7 @@ <script> import { GlFormGroup, GlFormCheckbox, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui'; import { mapGetters } from 'vuex'; -import { - VALIDATE_INTEGRATION_FORM_EVENT, - GET_JIRA_ISSUE_TYPES_EVENT, -} from '~/integrations/constants'; +import { VALIDATE_INTEGRATION_FORM_EVENT } from '~/integrations/constants'; import { s__, __ } from '~/locale'; import eventHub from '../event_hub'; import JiraUpgradeCta from './jira_upgrade_cta.vue'; @@ -91,9 +88,6 @@ export default { validateForm() { this.validated = true; }, - getJiraIssueTypes() { - eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT); - }, }, i18n: { sectionTitle: s__('JiraService|View Jira issues in GitLab'), @@ -123,7 +117,11 @@ export default { </p> <template v-if="showJiraIssuesIntegration"> <input name="service[issues_enabled]" type="hidden" :value="enableJiraIssues || false" /> - <gl-form-checkbox v-model="enableJiraIssues" :disabled="isInheriting"> + <gl-form-checkbox + v-model="enableJiraIssues" + :disabled="isInheriting" + data-qa-selector="service_jira_issues_enabled_checkbox" + > {{ $options.i18n.enableCheckboxLabel }} <template #help> {{ $options.i18n.enableCheckboxHelp }} @@ -136,7 +134,7 @@ export default { :initial-issue-type-id="initialVulnerabilitiesIssuetype" :show-full-feature="showJiraVulnerabilitiesIntegration" data-testid="jira-for-vulnerabilities" - @request-get-issue-types="getJiraIssueTypes" + @request-jira-issue-types="$emit('request-jira-issue-types')" /> <jira-upgrade-cta v-if="!showJiraVulnerabilitiesIntegration" @@ -168,6 +166,7 @@ export default { id="service_project_key" v-model="projectKey" name="service[project_key]" + data-qa-selector="service_jira_project_key_field" :placeholder="$options.i18n.projectKeyPlaceholder" :required="enableJiraIssues" :state="validProjectKey" diff --git a/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue b/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue index 9472a3eeafe..5a445235219 100644 --- a/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue +++ b/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue @@ -1,6 +1,5 @@ <script> import { GlModal } from '@gitlab/ui'; -import { mapGetters } from 'vuex'; import { __ } from '~/locale'; @@ -9,15 +8,10 @@ export default { GlModal, }, computed: { - ...mapGetters(['isDisabled']), primaryProps() { return { text: __('Reset'), - attributes: [ - { variant: 'warning' }, - { category: 'primary' }, - { disabled: this.isDisabled }, - ], + attributes: [{ variant: 'warning' }, { category: 'primary' }], }; }, cancelProps() { diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js index 792e7d8e85e..9c9e3edbeb8 100644 --- a/app/assets/javascripts/integrations/edit/index.js +++ b/app/assets/javascripts/integrations/edit/index.js @@ -85,35 +85,39 @@ function parseDatasetToProps(data) { }; } -export default (el, defaultEl) => { - if (!el) { +export default function initIntegrationSettingsForm(formSelector) { + const customSettingsEl = document.querySelector('.js-vue-integration-settings'); + const defaultSettingsEl = document.querySelector('.js-vue-default-integration-settings'); + + if (!customSettingsEl) { return null; } - const props = parseDatasetToProps(el.dataset); + const customSettingsProps = parseDatasetToProps(customSettingsEl.dataset); const initialState = { defaultState: null, - customState: props, + customState: customSettingsProps, }; - if (defaultEl) { - initialState.defaultState = Object.freeze(parseDatasetToProps(defaultEl.dataset)); + if (defaultSettingsEl) { + initialState.defaultState = Object.freeze(parseDatasetToProps(defaultSettingsEl.dataset)); } // Here, we capture the "helpHtml", so we can pass it to the Vue component // to position it where ever it wants. // Because this node is a _child_ of `el`, it will be removed when the Vue component is mounted, // so we don't need to manually remove it. - const helpHtml = el.querySelector('.js-integration-help-html')?.innerHTML; + const helpHtml = customSettingsEl.querySelector('.js-integration-help-html')?.innerHTML; return new Vue({ - el, + el: customSettingsEl, store: createStore(initialState), render(createElement) { return createElement(IntegrationForm, { props: { helpHtml, + formSelector, }, }); }, }); -}; +} diff --git a/app/assets/javascripts/integrations/edit/store/actions.js b/app/assets/javascripts/integrations/edit/store/actions.js index 400397c050c..97565a3a69c 100644 --- a/app/assets/javascripts/integrations/edit/store/actions.js +++ b/app/assets/javascripts/integrations/edit/store/actions.js @@ -1,10 +1,15 @@ import axios from 'axios'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; +import { + VALIDATE_INTEGRATION_FORM_EVENT, + I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE, + I18N_DEFAULT_ERROR_MESSAGE, +} from '~/integrations/constants'; +import { testIntegrationSettings } from '../api'; +import eventHub from '../event_hub'; import * as types from './mutation_types'; export const setOverride = ({ commit }, override) => commit(types.SET_OVERRIDE, override); -export const setIsSaving = ({ commit }, isSaving) => commit(types.SET_IS_SAVING, isSaving); -export const setIsTesting = ({ commit }, isTesting) => commit(types.SET_IS_TESTING, isTesting); export const setIsResetting = ({ commit }, isResetting) => commit(types.SET_IS_RESETTING, isResetting); @@ -27,10 +32,28 @@ export const fetchResetIntegration = ({ dispatch, getters }) => { .catch(() => dispatch('receiveResetIntegrationError')); }; -export const requestJiraIssueTypes = ({ commit }) => { +export const requestJiraIssueTypes = ({ commit, dispatch, getters }, formData) => { commit(types.SET_JIRA_ISSUE_TYPES_ERROR_MESSAGE, ''); commit(types.SET_IS_LOADING_JIRA_ISSUE_TYPES, true); + + return testIntegrationSettings(getters.propsSource.testPath, formData) + .then( + ({ + data: { issuetypes, error, message = I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE }, + }) => { + if (error || !issuetypes?.length) { + eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); + throw new Error(message); + } + + dispatch('receiveJiraIssueTypesSuccess', issuetypes); + }, + ) + .catch(({ message = I18N_DEFAULT_ERROR_MESSAGE }) => { + dispatch('receiveJiraIssueTypesError', message); + }); }; + export const receiveJiraIssueTypesSuccess = ({ commit }, issueTypes = []) => { commit(types.SET_IS_LOADING_JIRA_ISSUE_TYPES, false); commit(types.SET_JIRA_ISSUE_TYPES, issueTypes); diff --git a/app/assets/javascripts/integrations/edit/store/getters.js b/app/assets/javascripts/integrations/edit/store/getters.js index 39e14de2d0d..b79132128cc 100644 --- a/app/assets/javascripts/integrations/edit/store/getters.js +++ b/app/assets/javascripts/integrations/edit/store/getters.js @@ -1,7 +1,5 @@ export const isInheriting = (state) => (state.defaultState === null ? false : !state.override); -export const isDisabled = (state) => state.isSaving || state.isTesting || state.isResetting; - export const propsSource = (state, getters) => getters.isInheriting ? state.defaultState : state.customState; diff --git a/app/assets/javascripts/integrations/edit/store/mutation_types.js b/app/assets/javascripts/integrations/edit/store/mutation_types.js index c681056a515..ddf6bef7554 100644 --- a/app/assets/javascripts/integrations/edit/store/mutation_types.js +++ b/app/assets/javascripts/integrations/edit/store/mutation_types.js @@ -1,6 +1,4 @@ export const SET_OVERRIDE = 'SET_OVERRIDE'; -export const SET_IS_SAVING = 'SET_IS_SAVING'; -export const SET_IS_TESTING = 'SET_IS_TESTING'; export const SET_IS_RESETTING = 'SET_IS_RESETTING'; export const SET_IS_LOADING_JIRA_ISSUE_TYPES = 'SET_IS_LOADING_JIRA_ISSUE_TYPES'; diff --git a/app/assets/javascripts/integrations/edit/store/mutations.js b/app/assets/javascripts/integrations/edit/store/mutations.js index 279df1b9266..e7e312ce650 100644 --- a/app/assets/javascripts/integrations/edit/store/mutations.js +++ b/app/assets/javascripts/integrations/edit/store/mutations.js @@ -4,12 +4,6 @@ export default { [types.SET_OVERRIDE](state, override) { state.override = override; }, - [types.SET_IS_SAVING](state, isSaving) { - state.isSaving = isSaving; - }, - [types.SET_IS_TESTING](state, isTesting) { - state.isTesting = isTesting; - }, [types.SET_IS_RESETTING](state, isResetting) { state.isResetting = isResetting; }, diff --git a/app/assets/javascripts/integrations/edit/store/state.js b/app/assets/javascripts/integrations/edit/store/state.js index 1c0b274e4ef..3d40d1b90d5 100644 --- a/app/assets/javascripts/integrations/edit/store/state.js +++ b/app/assets/javascripts/integrations/edit/store/state.js @@ -6,7 +6,6 @@ export default ({ defaultState = null, customState = {} } = {}) => { defaultState, customState, isSaving: false, - isTesting: false, isResetting: false, isLoadingJiraIssueTypes: false, loadingJiraIssueTypesErrorMessage: '', diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js deleted file mode 100644 index f519fc87c46..00000000000 --- a/app/assets/javascripts/integrations/integration_settings_form.js +++ /dev/null @@ -1,151 +0,0 @@ -import { delay } from 'lodash'; -import toast from '~/vue_shared/plugins/global_toast'; -import axios from '../lib/utils/axios_utils'; -import initForm from './edit'; -import eventHub from './edit/event_hub'; -import { - TEST_INTEGRATION_EVENT, - SAVE_INTEGRATION_EVENT, - GET_JIRA_ISSUE_TYPES_EVENT, - TOGGLE_INTEGRATION_EVENT, - VALIDATE_INTEGRATION_FORM_EVENT, - I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE, - I18N_DEFAULT_ERROR_MESSAGE, - I18N_SUCCESSFUL_CONNECTION_MESSAGE, -} from './constants'; - -export default class IntegrationSettingsForm { - constructor(formSelector) { - this.$form = document.querySelector(formSelector); - this.formActive = false; - - this.vue = null; - - // Form Metadata - this.testEndPoint = this.$form.dataset.testUrl; - } - - init() { - // Init Vue component - this.vue = initForm( - document.querySelector('.js-vue-integration-settings'), - document.querySelector('.js-vue-default-integration-settings'), - ); - eventHub.$on(TOGGLE_INTEGRATION_EVENT, (active) => { - this.formActive = active; - this.toggleServiceState(); - }); - eventHub.$on(TEST_INTEGRATION_EVENT, () => { - this.testIntegration(); - }); - eventHub.$on(SAVE_INTEGRATION_EVENT, () => { - this.saveIntegration(); - }); - eventHub.$on(GET_JIRA_ISSUE_TYPES_EVENT, () => { - this.getJiraIssueTypes(new FormData(this.$form)); - }); - } - - saveIntegration() { - // Save Service if not active and check the following if active; - // 1) If form contents are valid - // 2) If this service can be saved - // If both conditions are true, we override form submission - // and save the service using provided configuration. - const formValid = this.$form.checkValidity() || this.formActive === false; - - if (formValid) { - delay(() => { - this.$form.submit(); - }, 100); - } else { - eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); - this.vue.$store.dispatch('setIsSaving', false); - } - } - - testIntegration() { - // Service was marked active so now we check; - // 1) If form contents are valid - // 2) If this service can be tested - // If both conditions are true, we override form submission - // and test the service using provided configuration. - if (this.$form.checkValidity()) { - this.testSettings(new FormData(this.$form)); - } else { - eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); - this.vue.$store.dispatch('setIsTesting', false); - } - } - - /** - * Change Form's validation enforcement based on service status (active/inactive) - */ - toggleServiceState() { - if (this.formActive) { - this.$form.removeAttribute('novalidate'); - } else if (!this.$form.getAttribute('novalidate')) { - this.$form.setAttribute('novalidate', 'novalidate'); - } - } - - /** - * Get a list of Jira issue types for the currently configured project - * - * @param {string} formData - URL encoded string containing the form data - * - * @return {Promise} - */ - getJiraIssueTypes(formData) { - const { - $store: { dispatch }, - } = this.vue; - - dispatch('requestJiraIssueTypes'); - - return this.fetchTestSettings(formData) - .then( - ({ - data: { issuetypes, error, message = I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE }, - }) => { - if (error || !issuetypes?.length) { - eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT); - throw new Error(message); - } - - dispatch('receiveJiraIssueTypesSuccess', issuetypes); - }, - ) - .catch(({ message = I18N_DEFAULT_ERROR_MESSAGE }) => { - dispatch('receiveJiraIssueTypesError', message); - }); - } - - /** - * Send request to the test endpoint which checks if the current config is valid - */ - fetchTestSettings(formData) { - return axios.put(this.testEndPoint, formData); - } - - /** - * Test Integration config - */ - testSettings(formData) { - return this.fetchTestSettings(formData) - .then(({ data }) => { - if (data.error) { - toast(`${data.message} ${data.service_response}`); - } else { - this.vue.$store.dispatch('receiveJiraIssueTypesSuccess', data.issuetypes); - toast(I18N_SUCCESSFUL_CONNECTION_MESSAGE); - } - }) - .catch(() => { - toast(I18N_DEFAULT_ERROR_MESSAGE); - }) - .finally(() => { - this.vue.$store.dispatch('setIsTesting', false); - }); - } -} diff --git a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue index 85018f133cb..3fc554c5371 100644 --- a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue +++ b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue @@ -6,8 +6,12 @@ import { DEFAULT_PER_PAGE } from '~/api'; import { fetchOverrides } from '~/integrations/overrides/api'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { truncateNamespace } from '~/lib/utils/text_utility'; +import { getParameterByName } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; +import UrlSync from '~/vue_shared/components/url_sync.vue'; + +const DEFAULT_PAGE = 1; export default { name: 'IntegrationOverrides', @@ -18,6 +22,7 @@ export default { GlTable, GlAlert, ProjectAvatar, + UrlSync, }, props: { overridesPath: { @@ -35,7 +40,7 @@ export default { return { isLoading: true, overrides: [], - page: 1, + page: DEFAULT_PAGE, totalItems: 0, errorMessage: null, }; @@ -44,12 +49,21 @@ export default { showPagination() { return this.totalItems > this.$options.DEFAULT_PER_PAGE && this.overrides.length > 0; }, + query() { + return { + page: this.page, + }; + }, }, - mounted() { - this.loadOverrides(); + created() { + const initialPage = this.getInitialPage(); + this.loadOverrides(initialPage); }, methods: { - loadOverrides(page = this.page) { + getInitialPage() { + return getParameterByName('page') ?? DEFAULT_PAGE; + }, + loadOverrides(page) { this.isLoading = true; this.errorMessage = null; @@ -119,14 +133,16 @@ export default { </template> </gl-table> <div class="gl-display-flex gl-justify-content-center gl-mt-5"> - <gl-pagination - v-if="showPagination" - :per-page="$options.DEFAULT_PER_PAGE" - :total-items="totalItems" - :value="page" - :disabled="isLoading" - @input="loadOverrides" - /> + <template v-if="showPagination"> + <gl-pagination + :per-page="$options.DEFAULT_PER_PAGE" + :total-items="totalItems" + :value="page" + :disabled="isLoading" + @input="loadOverrides" + /> + <url-sync :query="query" /> + </template> </div> </div> </template> diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue index cf4f434a7a8..91a139a5105 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -20,12 +20,11 @@ import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { getParameterValues } from '~/lib/utils/url_utility'; import { sprintf } from '~/locale'; import { - INVITE_MEMBERS_IN_COMMENT, GROUP_FILTERS, USERS_FILTER_ALL, - MEMBER_AREAS_OF_FOCUS, INVITE_MEMBERS_FOR_TASK, MODAL_LABELS, + LEARN_GITLAB, } from '../constants'; import eventHub from '../event_hub'; import { @@ -100,14 +99,6 @@ export default { type: String, required: true, }, - areasOfFocusOptions: { - type: Array, - required: true, - }, - noSelectionAreasOfFocus: { - type: Array, - required: true, - }, tasksToBeDoneOptions: { type: Array, required: true, @@ -125,7 +116,6 @@ export default { inviteeType: 'members', newUsersToInvite: [], selectedDate: undefined, - selectedAreasOfFocus: [], selectedTasksToBeDone: [], selectedTaskProject: this.projects[0], groupToBeSharedWith: {}, @@ -181,16 +171,6 @@ export default { this.newUsersToInvite.length === 0 && Object.keys(this.groupToBeSharedWith).length === 0 ); }, - areasOfFocusEnabled() { - return !this.tasksToBeDoneEnabled && this.areasOfFocusOptions.length !== 0; - }, - areasOfFocusForPost() { - if (this.selectedAreasOfFocus.length === 0 && this.areasOfFocusEnabled) { - return this.noSelectionAreasOfFocus; - } - - return this.selectedAreasOfFocus; - }, errorFieldDescription() { if (this.inviteeType === 'group') { return ''; @@ -200,7 +180,8 @@ export default { }, tasksToBeDoneEnabled() { return ( - getParameterValues('open_modal')[0] === 'invite_members_for_task' && + (getParameterValues('open_modal')[0] === 'invite_members_for_task' || + this.isOnLearnGitlab) && this.tasksToBeDoneOptions.length ); }, @@ -221,11 +202,16 @@ export default { ? this.selectedTaskProject.id : ''; }, + isOnLearnGitlab() { + return this.source === LEARN_GITLAB; + }, }, mounted() { eventHub.$on('openModal', (options) => { this.openModal(options); - this.trackEvent(MEMBER_AREAS_OF_FOCUS.name, MEMBER_AREAS_OF_FOCUS.view); + if (this.isOnLearnGitlab) { + this.trackEvent(INVITE_MEMBERS_FOR_TASK.name, this.source); + } }); if (this.tasksToBeDoneEnabled) { @@ -267,13 +253,6 @@ export default { this.submitInviteMembers(); } }, - trackInvite() { - if (this.source === INVITE_MEMBERS_IN_COMMENT) { - this.trackEvent(INVITE_MEMBERS_IN_COMMENT, 'comment_invite_success'); - } - - this.trackEvent(MEMBER_AREAS_OF_FOCUS.name, MEMBER_AREAS_OF_FOCUS.submit); - }, trackinviteMembersForTask() { const label = 'selected_tasks_to_be_done'; const property = this.selectedTasksToBeDone.join(','); @@ -287,7 +266,6 @@ export default { this.newUsersToInvite = []; this.groupToBeSharedWith = {}; this.invalidFeedbackMessage = ''; - this.selectedAreasOfFocus = []; this.selectedTasksToBeDone = []; [this.selectedTaskProject] = this.projects; }, @@ -303,7 +281,7 @@ export default { : Api.groupShareWithGroup.bind(Api); apiShareWithGroup(this.id, this.shareWithGroupPostData(this.groupToBeSharedWith.id)) - .then(this.showToastMessageSuccess) + .then(this.showSuccessMessage) .catch(this.showInvalidFeedbackMessage); }, submitInviteMembers() { @@ -328,11 +306,10 @@ export default { promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById))); } - this.trackInvite(); this.trackinviteMembersForTask(); Promise.all(promises) - .then(this.conditionallyShowToastSuccess) + .then(this.conditionallyShowSuccessMessage) .catch(this.showInvalidFeedbackMessage); }, inviteByEmailPostData(usersToInviteByEmail) { @@ -341,7 +318,6 @@ export default { email: usersToInviteByEmail, access_level: this.selectedAccessLevel, invite_source: this.source, - areas_of_focus: this.areasOfFocusForPost, tasks_to_be_done: this.tasksToBeDoneForPost, tasks_project_id: this.tasksProjectForPost, }; @@ -352,7 +328,6 @@ export default { user_id: usersToAddById, access_level: this.selectedAccessLevel, invite_source: this.source, - areas_of_focus: this.areasOfFocusForPost, tasks_to_be_done: this.tasksToBeDoneForPost, tasks_project_id: this.tasksProjectForPost, }; @@ -364,11 +339,11 @@ export default { group_access: this.selectedAccessLevel, }; }, - conditionallyShowToastSuccess(response) { + conditionallyShowSuccessMessage(response) { const message = this.unescapeMsg(responseMessageFromSuccess(response)); if (message === '') { - this.showToastMessageSuccess(); + this.showSuccessMessage(); return; } @@ -376,8 +351,12 @@ export default { this.invalidFeedbackMessage = message; this.isLoading = false; }, - showToastMessageSuccess() { - this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions); + showSuccessMessage() { + if (this.isOnLearnGitlab) { + eventHub.$emit('showSuccessfulInvitationsAlert'); + } else { + this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions); + } this.closeModal(); }, showInvalidFeedbackMessage(response) { @@ -504,16 +483,6 @@ export default { </template> </gl-datepicker> </div> - <div v-if="areasOfFocusEnabled"> - <label class="gl-mt-5"> - {{ $options.labels.areasOfFocusLabel }} - </label> - <gl-form-checkbox-group - v-model="selectedAreasOfFocus" - :options="areasOfFocusOptions" - data-testid="area-of-focus-checks" - /> - </div> <div v-if="showTasksToBeDone" data-testid="invite-members-modal-tasks-to-be-done"> <label class="gl-mt-5"> {{ $options.labels.members.tasksToBeDone.title }} diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue index bf3250f63a5..7dd74f8803a 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue @@ -1,6 +1,5 @@ <script> import { GlButton, GlLink, GlIcon } from '@gitlab/ui'; -import ExperimentTracking from '~/experimentation/experiment_tracking'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; import { TRIGGER_ELEMENT_BUTTON, TRIGGER_ELEMENT_SIDE_NAV } from '../constants'; @@ -32,11 +31,6 @@ export default { type: String, required: true, }, - trackExperiment: { - type: String, - required: false, - default: undefined, - }, triggerElement: { type: String, required: false, @@ -72,9 +66,6 @@ export default { return baseAttributes; }, }, - mounted() { - this.trackExperimentOnShow(); - }, methods: { checkTrigger(targetTriggerElement) { return this.triggerElement === targetTriggerElement; @@ -82,12 +73,6 @@ export default { openModal() { eventHub.$emit('openModal', { inviteeType: 'members', source: this.triggerSource }); }, - trackExperimentOnShow() { - if (this.trackExperiment) { - const tracking = new ExperimentTracking(this.trackExperiment); - tracking.event('comment_invite_shown'); - } - }, }, TRIGGER_ELEMENT_BUTTON, TRIGGER_ELEMENT_SIDE_NAV, diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index 59d4c2f3077..ec59b3909fe 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -2,12 +2,6 @@ import { __, s__ } from '~/locale'; export const SEARCH_DELAY = 200; -export const INVITE_MEMBERS_IN_COMMENT = 'invite_members_in_comment'; -export const MEMBER_AREAS_OF_FOCUS = { - name: 'member_areas_of_focus', - view: 'view', - submit: 'submit', -}; export const INVITE_MEMBERS_FOR_TASK = { minimum_access_level: 30, name: 'invite_members_for_task', @@ -77,9 +71,6 @@ export const READ_MORE_TEXT = s__( export const INVITE_BUTTON_TEXT = s__('InviteMembersModal|Invite'); export const CANCEL_BUTTON_TEXT = s__('InviteMembersModal|Cancel'); export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team members'); -export const AREAS_OF_FOCUS_LABEL = s__( - 'InviteMembersModal|What would you like new member(s) to focus on? (optional)', -); export const MODAL_LABELS = { members: { @@ -142,5 +133,6 @@ export const MODAL_LABELS = { inviteButtonText: INVITE_BUTTON_TEXT, cancelButtonText: CANCEL_BUTTON_TEXT, headerCloseLabel: HEADER_CLOSE_LABEL, - areasOfFocusLabel: AREAS_OF_FOCUS_LABEL, }; + +export const LEARN_GITLAB = 'learn_gitlab'; diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js index fc657a064dd..2cc056f2ddb 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -40,10 +40,8 @@ export default function initInviteMembersModal() { defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10), groupSelectFilter: el.dataset.groupsFilter, groupSelectParentId: parseInt(el.dataset.parentId, 10), - areasOfFocusOptions: JSON.parse(el.dataset.areasOfFocusOptions), tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions || '[]'), projects: JSON.parse(el.dataset.projects || '[]'), - noSelectionAreasOfFocus: JSON.parse(el.dataset.noSelectionAreasOfFocus), usersFilter: el.dataset.usersFilter, filterId: parseInt(el.dataset.filterId, 10), }, diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/components/status_select.vue b/app/assets/javascripts/issuable/bulk_update_sidebar/components/status_select.vue index 9509399e91d..9509399e91d 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar/components/status_select.vue +++ b/app/assets/javascripts/issuable/bulk_update_sidebar/components/status_select.vue diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/constants.js b/app/assets/javascripts/issuable/bulk_update_sidebar/constants.js index ad15b25f9cf..ad15b25f9cf 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar/constants.js +++ b/app/assets/javascripts/issuable/bulk_update_sidebar/constants.js diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/init_issue_status_select.js b/app/assets/javascripts/issuable/bulk_update_sidebar/init_issue_status_select.js index 43179a86d70..43179a86d70 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar/init_issue_status_select.js +++ b/app/assets/javascripts/issuable/bulk_update_sidebar/init_issue_status_select.js diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_actions.js index 463e0e5837e..14824820c0d 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_actions.js +++ b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_actions.js @@ -115,7 +115,7 @@ export default { }); // Add uniqueIds to add it as argument for _.intersection labelIds.unshift(uniqueIds); - // Return IDs that are present but not in all selected issueables + // Return IDs that are present but not in all selected issuables return uniqueIds.filter((x) => !intersection.apply(this, labelIds).includes(x)); }, diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js index a9d4548f8cf..1eb3ffc9808 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js @@ -3,9 +3,9 @@ import $ from 'jquery'; import { property } from 'lodash'; -import issueableEventHub from '~/issues_list/eventhub'; -import LabelsSelect from '~/labels_select'; -import MilestoneSelect from '~/milestone_select'; +import issuableEventHub from '~/issues_list/eventhub'; +import LabelsSelect from '~/labels/labels_select'; +import MilestoneSelect from '~/milestones/milestone_select'; import initIssueStatusSelect from './init_issue_status_select'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; import subscriptionSelect from './subscription_select'; @@ -50,8 +50,8 @@ export default class IssuableBulkUpdateSidebar { // The event hub connects this bulk update logic with `issues_list_app.vue`. // We can remove it once we've refactored the issues list page bulk edit sidebar to Vue. // https://gitlab.com/gitlab-org/gitlab/-/issues/325874 - issueableEventHub.$on('issuables:enableBulkEdit', () => this.toggleBulkEdit(null, true)); - issueableEventHub.$on('issuables:updateBulkEdit', () => this.updateFormState()); + issuableEventHub.$on('issuables:enableBulkEdit', () => this.toggleBulkEdit(null, true)); + issuableEventHub.$on('issuables:updateBulkEdit', () => this.updateFormState()); } initDropdowns() { @@ -110,7 +110,7 @@ export default class IssuableBulkUpdateSidebar { toggleBulkEdit(e, enable) { e?.preventDefault(); - issueableEventHub.$emit('issuables:toggleBulkEdit', enable); + issuableEventHub.$emit('issuables:toggleBulkEdit', enable); this.toggleSidebarDisplay(enable); this.toggleBulkEditButtonDisabled(enable); diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar.js b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar.js index 179c2b83c6c..179c2b83c6c 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar.js diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar/subscription_select.js b/app/assets/javascripts/issuable/bulk_update_sidebar/subscription_select.js index b12ac776b4f..b12ac776b4f 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar/subscription_select.js +++ b/app/assets/javascripts/issuable/bulk_update_sidebar/subscription_select.js diff --git a/app/assets/javascripts/issuable/components/issuable_by_email.vue b/app/assets/javascripts/issuable/components/issuable_by_email.vue index 799d2bdc9e2..512fa6f8c68 100644 --- a/app/assets/javascripts/issuable/components/issuable_by_email.vue +++ b/app/assets/javascripts/issuable/components/issuable_by_email.vue @@ -54,8 +54,7 @@ export default { data() { return { email: this.initialEmail, - // eslint-disable-next-line @gitlab/require-i18n-strings - issuableName: this.issuableType === 'issue' ? 'issue' : 'merge request', + issuableName: this.issuableType === 'issue' ? __('issue') : __('merge request'), }; }, computed: { diff --git a/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue index 82223ab9ef4..82223ab9ef4 100644 --- a/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue +++ b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/issuable/components/issue_assignees.vue index 5955f31fc70..5955f31fc70 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue +++ b/app/assets/javascripts/issuable/components/issue_assignees.vue diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue b/app/assets/javascripts/issuable/components/issue_milestone.vue index 6a0c21602bd..6a0c21602bd 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue +++ b/app/assets/javascripts/issuable/components/issue_milestone.vue diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue index 8aeff9257a5..2bb0e3c80f9 100644 --- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue +++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue @@ -9,8 +9,8 @@ import { } from '@gitlab/ui'; import IssueDueDate from '~/boards/components/issue_due_date.vue'; import { sprintf } from '~/locale'; -import relatedIssuableMixin from '../../mixins/related_issuable_mixin'; -import CiIcon from '../ci_icon.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import relatedIssuableMixin from '../mixins/related_issuable_mixin'; import IssueAssignees from './issue_assignees.vue'; import IssueMilestone from './issue_milestone.vue'; diff --git a/app/assets/javascripts/issuable/constants.js b/app/assets/javascripts/issuable/constants.js index 9344f4a7c9a..5327f251fda 100644 --- a/app/assets/javascripts/issuable/constants.js +++ b/app/assets/javascripts/issuable/constants.js @@ -4,3 +4,8 @@ export const ISSUABLE_TYPE = { issues: 'issues', mergeRequests: 'merge-requests', }; + +export const ISSUABLE_INDEX = { + ISSUE: 'issue_', + MERGE_REQUEST: 'merge_request_', +}; diff --git a/app/assets/javascripts/issuable/index.js b/app/assets/javascripts/issuable/index.js new file mode 100644 index 00000000000..072422944f5 --- /dev/null +++ b/app/assets/javascripts/issuable/index.js @@ -0,0 +1,116 @@ +import { GlToast } from '@gitlab/ui'; +import Vue from 'vue'; +import IssuableContext from '~/issuable/issuable_context'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import Sidebar from '~/right_sidebar'; +import { getSidebarOptions } from '~/sidebar/mount_sidebar'; +import CsvImportExportButtons from './components/csv_import_export_buttons.vue'; +import IssuableByEmail from './components/issuable_by_email.vue'; +import IssuableHeaderWarnings from './components/issuable_header_warnings.vue'; + +export function initCsvImportExportButtons() { + const el = document.querySelector('.js-csv-import-export-buttons'); + + if (!el) return null; + + const { + showExportButton, + showImportButton, + issuableType, + issuableCount, + email, + exportCsvPath, + importCsvIssuesPath, + containerClass, + canEdit, + projectImportJiraPath, + maxAttachmentSize, + showLabel, + } = el.dataset; + + return new Vue({ + el, + provide: { + showExportButton: parseBoolean(showExportButton), + showImportButton: parseBoolean(showImportButton), + issuableType, + email, + importCsvIssuesPath, + containerClass, + canEdit: parseBoolean(canEdit), + projectImportJiraPath, + maxAttachmentSize, + showLabel, + }, + render(h) { + return h(CsvImportExportButtons, { + props: { + exportCsvPath, + issuableCount: parseInt(issuableCount, 10), + }, + }); + }, + }); +} + +export function initIssuableByEmail() { + Vue.use(GlToast); + + const el = document.querySelector('.js-issuable-by-email'); + + if (!el) return null; + + const { + initialEmail, + issuableType, + emailsHelpPagePath, + quickActionsHelpPath, + markdownHelpPath, + resetPath, + } = el.dataset; + + return new Vue({ + el, + provide: { + initialEmail, + issuableType, + emailsHelpPagePath, + quickActionsHelpPath, + markdownHelpPath, + resetPath, + }, + render(h) { + return h(IssuableByEmail); + }, + }); +} + +export function initIssuableHeaderWarnings(store) { + const el = document.getElementById('js-issuable-header-warnings'); + + if (!el) { + return false; + } + + const { hidden } = el.dataset; + + return new Vue({ + el, + store, + provide: { hidden: parseBoolean(hidden) }, + render(createElement) { + return createElement(IssuableHeaderWarnings); + }, + }); +} + +export function initIssuableSidebar() { + const sidebarOptEl = document.querySelector('.js-sidebar-options'); + + if (!sidebarOptEl) return; + + const sidebarOptions = getSidebarOptions(sidebarOptEl); + + new IssuableContext(sidebarOptions.currentUser); // eslint-disable-line no-new + Sidebar.initialize(); +} diff --git a/app/assets/javascripts/issuable/init_csv_import_export_buttons.js b/app/assets/javascripts/issuable/init_csv_import_export_buttons.js deleted file mode 100644 index 83163e3c478..00000000000 --- a/app/assets/javascripts/issuable/init_csv_import_export_buttons.js +++ /dev/null @@ -1,48 +0,0 @@ -import Vue from 'vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import CsvImportExportButtons from './components/csv_import_export_buttons.vue'; - -export default () => { - const el = document.querySelector('.js-csv-import-export-buttons'); - - if (!el) return null; - - const { - showExportButton, - showImportButton, - issuableType, - issuableCount, - email, - exportCsvPath, - importCsvIssuesPath, - containerClass, - canEdit, - projectImportJiraPath, - maxAttachmentSize, - showLabel, - } = el.dataset; - - return new Vue({ - el, - provide: { - showExportButton: parseBoolean(showExportButton), - showImportButton: parseBoolean(showImportButton), - issuableType, - email, - importCsvIssuesPath, - containerClass, - canEdit: parseBoolean(canEdit), - projectImportJiraPath, - maxAttachmentSize, - showLabel, - }, - render(h) { - return h(CsvImportExportButtons, { - props: { - exportCsvPath, - issuableCount: parseInt(issuableCount, 10), - }, - }); - }, - }); -}; diff --git a/app/assets/javascripts/issuable/init_issuable_by_email.js b/app/assets/javascripts/issuable/init_issuable_by_email.js deleted file mode 100644 index 984b826234c..00000000000 --- a/app/assets/javascripts/issuable/init_issuable_by_email.js +++ /dev/null @@ -1,35 +0,0 @@ -import { GlToast } from '@gitlab/ui'; -import Vue from 'vue'; -import IssuableByEmail from './components/issuable_by_email.vue'; - -Vue.use(GlToast); - -export default () => { - const el = document.querySelector('.js-issueable-by-email'); - - if (!el) return null; - - const { - initialEmail, - issuableType, - emailsHelpPagePath, - quickActionsHelpPath, - markdownHelpPath, - resetPath, - } = el.dataset; - - return new Vue({ - el, - provide: { - initialEmail, - issuableType, - emailsHelpPagePath, - quickActionsHelpPath, - markdownHelpPath, - resetPath, - }, - render(h) { - return h(IssuableByEmail); - }, - }); -}; diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable/issuable_context.js index 51b5237a339..453305dd6e0 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable/issuable_context.js @@ -1,8 +1,8 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; import Cookies from 'js-cookie'; -import { loadCSSFile } from './lib/utils/css_utils'; -import UsersSelect from './users_select'; +import { loadCSSFile } from '~/lib/utils/css_utils'; +import UsersSelect from '~/users_select'; export default class IssuableContext { constructor(currentUser) { diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js index bafc26befda..91f47a86cb7 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable/issuable_form.js @@ -1,14 +1,14 @@ import $ from 'jquery'; import Pikaday from 'pikaday'; import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; -import Autosave from './autosave'; -import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; -import { loadCSSFile } from './lib/utils/css_utils'; -import { parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility'; -import { select2AxiosTransport } from './lib/utils/select2_utils'; -import { queryToObject, objectToQuery } from './lib/utils/url_utility'; -import UsersSelect from './users_select'; -import ZenMode from './zen_mode'; +import Autosave from '~/autosave'; +import AutoWidthDropdownSelect from '~/issuable/auto_width_dropdown_select'; +import { loadCSSFile } from '~/lib/utils/css_utils'; +import { parsePikadayDate, pikadayToString } from '~/lib/utils/datetime_utility'; +import { select2AxiosTransport } from '~/lib/utils/select2_utils'; +import { queryToObject, objectToQuery } from '~/lib/utils/url_utility'; +import UsersSelect from '~/users_select'; +import ZenMode from '~/zen_mode'; const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; const MR_TARGET_BRANCH = 'merge_request[target_branch]'; diff --git a/app/assets/javascripts/templates/issuable_template_selector.js b/app/assets/javascripts/issuable/issuable_template_selector.js index 1bb5e214c2e..cce903d388d 100644 --- a/app/assets/javascripts/templates/issuable_template_selector.js +++ b/app/assets/javascripts/issuable/issuable_template_selector.js @@ -1,9 +1,7 @@ -/* eslint-disable no-useless-return */ - import $ from 'jquery'; +import TemplateSelector from '~/blob/template_selector'; import { __ } from '~/locale'; import Api from '../api'; -import TemplateSelector from '../blob/template_selector'; export default class IssuableTemplateSelector extends TemplateSelector { constructor(...args) { @@ -109,7 +107,5 @@ export default class IssuableTemplateSelector extends TemplateSelector { } else { this.setEditorContent(this.currentTemplate, { skipFocus: false }); } - - return; } } diff --git a/app/assets/javascripts/templates/issuable_template_selectors.js b/app/assets/javascripts/issuable/issuable_template_selectors.js index 443b3084113..92f825e55d3 100644 --- a/app/assets/javascripts/templates/issuable_template_selectors.js +++ b/app/assets/javascripts/issuable/issuable_template_selectors.js @@ -1,5 +1,3 @@ -/* eslint-disable no-new, class-methods-use-this */ - import $ from 'jquery'; import IssuableTemplateSelector from './issuable_template_selector'; @@ -10,6 +8,8 @@ export default class IssuableTemplateSelectors { this.$dropdowns.each((i, dropdown) => { const $dropdown = $(dropdown); + + // eslint-disable-next-line no-new new IssuableTemplateSelector({ pattern: /(\.md)/, data: $dropdown.data('data'), @@ -21,6 +21,7 @@ export default class IssuableTemplateSelectors { }); } + // eslint-disable-next-line class-methods-use-this initEditor() { const editor = $('.markdown-area'); // Proxy ace-editor's .setValue to jQuery's .val diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js index 4a6edae0c06..4a6edae0c06 100644 --- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js +++ b/app/assets/javascripts/issuable/mixins/related_issuable_mixin.js diff --git a/app/assets/javascripts/issuable_index.js b/app/assets/javascripts/issuable_index.js deleted file mode 100644 index 5a57da292a0..00000000000 --- a/app/assets/javascripts/issuable_index.js +++ /dev/null @@ -1,7 +0,0 @@ -import issuableInitBulkUpdateSidebar from '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar'; - -export default class IssuableIndex { - constructor(pagePrefix = 'issuable_') { - issuableInitBulkUpdateSidebar.init(pagePrefix); - } -} diff --git a/app/assets/javascripts/issuable_type_selector/index.js b/app/assets/javascripts/issuable_type_selector/index.js deleted file mode 100644 index 433a62d1ae8..00000000000 --- a/app/assets/javascripts/issuable_type_selector/index.js +++ /dev/null @@ -1,16 +0,0 @@ -import Vue from 'vue'; -import InfoPopover from './components/info_popover.vue'; - -export default function initIssuableTypeSelector() { - const el = document.getElementById('js-type-popover'); - - return new Vue({ - el, - components: { - InfoPopover, - }, - render(h) { - return h(InfoPopover); - }, - }); -} diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js new file mode 100644 index 00000000000..b7b123dfd5f --- /dev/null +++ b/app/assets/javascripts/issues/constants.js @@ -0,0 +1,25 @@ +import { __ } from '~/locale'; + +export const IssuableStatus = { + Closed: 'closed', + Open: 'opened', + Reopened: 'reopened', +}; + +export const IssuableStatusText = { + [IssuableStatus.Closed]: __('Closed'), + [IssuableStatus.Open]: __('Open'), + [IssuableStatus.Reopened]: __('Open'), +}; + +export const IssuableType = { + Issue: 'issue', + Epic: 'epic', + MergeRequest: 'merge_request', + Alert: 'alert', +}; + +export const WorkspaceType = { + project: 'project', + group: 'group', +}; diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js b/app/assets/javascripts/issues/filtered_search_service_desk.js index bec207aa439..bec207aa439 100644 --- a/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js +++ b/app/assets/javascripts/issues/filtered_search_service_desk.js diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/issues/form.js index c0da0069a99..33371d065f9 100644 --- a/app/assets/javascripts/pages/projects/issues/form.js +++ b/app/assets/javascripts/issues/form.js @@ -1,14 +1,13 @@ /* eslint-disable no-new */ import $ from 'jquery'; -import IssuableForm from 'ee_else_ce/issuable_form'; +import IssuableForm from 'ee_else_ce/issuable/issuable_form'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import GLForm from '~/gl_form'; -import initSuggestions from '~/issuable_suggestions'; -import initIssuableTypeSelector from '~/issuable_type_selector'; -import LabelsSelect from '~/labels_select'; -import MilestoneSelect from '~/milestone_select'; -import IssuableTemplateSelectors from '~/templates/issuable_template_selectors'; +import { initTitleSuggestions, initTypePopover } from '~/issues/new'; +import LabelsSelect from '~/labels/labels_select'; +import MilestoneSelect from '~/milestones/milestone_select'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; export default () => { new ShortcutsNavigation(); @@ -20,6 +19,6 @@ export default () => { warnTemplateOverride: true, }); - initSuggestions(); - initIssuableTypeSelector(); + initTitleSuggestions(); + initTypePopover(); }; diff --git a/app/assets/javascripts/issues/init_filtered_search_service_desk.js b/app/assets/javascripts/issues/init_filtered_search_service_desk.js new file mode 100644 index 00000000000..1901802c11c --- /dev/null +++ b/app/assets/javascripts/issues/init_filtered_search_service_desk.js @@ -0,0 +1,11 @@ +import FilteredSearchServiceDesk from './filtered_search_service_desk'; + +export function initFilteredSearchServiceDesk() { + if (document.querySelector('.filtered-search')) { + const supportBotData = JSON.parse( + document.querySelector('.js-service-desk-issues').dataset.supportBot, + ); + const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData); + filteredSearchManager.setup(); + } +} diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issues/issue.js index 1e053d7daaa..c471875654b 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issues/issue.js @@ -1,11 +1,11 @@ import $ from 'jquery'; import { joinPaths } from '~/lib/utils/url_utility'; -import CreateMergeRequestDropdown from './create_merge_request_dropdown'; -import createFlash from './flash'; -import { EVENT_ISSUABLE_VUE_APP_CHANGE } from './issuable/constants'; -import axios from './lib/utils/axios_utils'; -import { addDelimiter } from './lib/utils/text_utility'; -import { __ } from './locale'; +import CreateMergeRequestDropdown from '~/create_merge_request_dropdown'; +import createFlash from '~/flash'; +import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; +import axios from '~/lib/utils/axios_utils'; +import { addDelimiter } from '~/lib/utils/text_utility'; +import { __ } from '~/locale'; export default class Issue { constructor() { diff --git a/app/assets/javascripts/manual_ordering.js b/app/assets/javascripts/issues/manual_ordering.js index 9613246d6a6..9613246d6a6 100644 --- a/app/assets/javascripts/manual_ordering.js +++ b/app/assets/javascripts/issues/manual_ordering.js diff --git a/app/assets/javascripts/issuable_suggestions/components/app.vue b/app/assets/javascripts/issues/new/components/title_suggestions.vue index 48a5e220abf..0a9cdb12519 100644 --- a/app/assets/javascripts/issuable_suggestions/components/app.vue +++ b/app/assets/javascripts/issues/new/components/title_suggestions.vue @@ -2,12 +2,12 @@ import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import query from '../queries/issues.query.graphql'; -import Suggestion from './item.vue'; +import TitleSuggestionsItem from './title_suggestions_item.vue'; export default { components: { - Suggestion, GlIcon, + TitleSuggestionsItem, }, directives: { GlTooltip: GlTooltipDirective, @@ -66,7 +66,7 @@ export default { </script> <template> - <div v-show="showSuggestions" class="form-group row issuable-suggestions"> + <div v-show="showSuggestions" class="form-group row"> <div v-once class="col-form-label col-sm-2 pt-0"> {{ __('Similar issues') }} <gl-icon @@ -86,7 +86,7 @@ export default { 'gl-mb-3': index !== issues.length - 1, }" > - <suggestion :suggestion="suggestion" /> + <title-suggestions-item :suggestion="suggestion" /> </li> </ul> </div> diff --git a/app/assets/javascripts/issuable_suggestions/components/item.vue b/app/assets/javascripts/issues/new/components/title_suggestions_item.vue index a01f4f747b9..a01f4f747b9 100644 --- a/app/assets/javascripts/issuable_suggestions/components/item.vue +++ b/app/assets/javascripts/issues/new/components/title_suggestions_item.vue diff --git a/app/assets/javascripts/issuable_type_selector/components/info_popover.vue b/app/assets/javascripts/issues/new/components/type_popover.vue index 3a20ccba814..a70e79b70f9 100644 --- a/app/assets/javascripts/issuable_type_selector/components/info_popover.vue +++ b/app/assets/javascripts/issues/new/components/type_popover.vue @@ -19,9 +19,9 @@ export default { <template> <span id="popovercontainer"> - <gl-icon id="issuable-type-info" name="question-o" class="gl-ml-5 gl-text-gray-500" /> + <gl-icon id="issue-type-info" name="question-o" class="gl-ml-5 gl-text-gray-500" /> <gl-popover - target="issuable-type-info" + target="issue-type-info" container="popovercontainer" :title="$options.i18n.issueTypes" triggers="focus hover" diff --git a/app/assets/javascripts/issuable_suggestions/index.js b/app/assets/javascripts/issues/new/index.js index 8f7f317d6b4..59a7cbec627 100644 --- a/app/assets/javascripts/issuable_suggestions/index.js +++ b/app/assets/javascripts/issues/new/index.js @@ -1,14 +1,19 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import App from './components/app.vue'; +import TitleSuggestions from './components/title_suggestions.vue'; +import TypePopover from './components/type_popover.vue'; -Vue.use(VueApollo); +export function initTitleSuggestions() { + Vue.use(VueApollo); -export default function initIssuableSuggestions() { const el = document.getElementById('js-suggestions'); const issueTitle = document.getElementById('issue_title'); - const { projectPath } = el.dataset; + + if (!el) { + return undefined; + } + const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), }); @@ -26,13 +31,26 @@ export default function initIssuableSuggestions() { this.search = issueTitle.value; }); }, - render(h) { - return h(App, { + render(createElement) { + return createElement(TitleSuggestions, { props: { - projectPath, + projectPath: el.dataset.projectPath, search: this.search, }, }); }, }); } + +export function initTypePopover() { + const el = document.getElementById('js-type-popover'); + + if (!el) { + return undefined; + } + + return new Vue({ + el, + render: (createElement) => createElement(TypePopover), + }); +} diff --git a/app/assets/javascripts/issuable_suggestions/queries/issues.query.graphql b/app/assets/javascripts/issues/new/queries/issues.query.graphql index 2384b381344..dc0757b141f 100644 --- a/app/assets/javascripts/issuable_suggestions/queries/issues.query.graphql +++ b/app/assets/javascripts/issues/new/queries/issues.query.graphql @@ -1,8 +1,10 @@ query issueSuggestion($fullPath: ID!, $search: String) { project(fullPath: $fullPath) { + id issues(search: $search, sort: updated_desc, first: 5) { edges { node { + id iid title confidential @@ -14,6 +16,7 @@ query issueSuggestion($fullPath: ID!, $search: String) { createdAt updatedAt author { + id name username avatarUrl diff --git a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue index 50835142d28..1d48446b083 100644 --- a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue +++ b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue @@ -2,8 +2,8 @@ import { GlLink, GlLoadingIcon, GlIcon } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import { sprintf, __, n__ } from '~/locale'; -import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; -import { parseIssuableData } from '../../issue_show/utils/parse_data'; +import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue'; +import { parseIssuableData } from '~/issues/show/utils/parse_data'; export default { name: 'RelatedMergeRequests', diff --git a/app/assets/javascripts/related_merge_requests/index.js b/app/assets/javascripts/issues/related_merge_requests/index.js index ce33cf7df1d..ce33cf7df1d 100644 --- a/app/assets/javascripts/related_merge_requests/index.js +++ b/app/assets/javascripts/issues/related_merge_requests/index.js diff --git a/app/assets/javascripts/related_merge_requests/store/actions.js b/app/assets/javascripts/issues/related_merge_requests/store/actions.js index 94abb50de89..94abb50de89 100644 --- a/app/assets/javascripts/related_merge_requests/store/actions.js +++ b/app/assets/javascripts/issues/related_merge_requests/store/actions.js diff --git a/app/assets/javascripts/related_merge_requests/store/index.js b/app/assets/javascripts/issues/related_merge_requests/store/index.js index 925cc36cd76..925cc36cd76 100644 --- a/app/assets/javascripts/related_merge_requests/store/index.js +++ b/app/assets/javascripts/issues/related_merge_requests/store/index.js diff --git a/app/assets/javascripts/related_merge_requests/store/mutation_types.js b/app/assets/javascripts/issues/related_merge_requests/store/mutation_types.js index 31d4fe032e1..31d4fe032e1 100644 --- a/app/assets/javascripts/related_merge_requests/store/mutation_types.js +++ b/app/assets/javascripts/issues/related_merge_requests/store/mutation_types.js diff --git a/app/assets/javascripts/related_merge_requests/store/mutations.js b/app/assets/javascripts/issues/related_merge_requests/store/mutations.js index 11ca28a5fb9..11ca28a5fb9 100644 --- a/app/assets/javascripts/related_merge_requests/store/mutations.js +++ b/app/assets/javascripts/issues/related_merge_requests/store/mutations.js diff --git a/app/assets/javascripts/related_merge_requests/store/state.js b/app/assets/javascripts/issues/related_merge_requests/store/state.js index bc3468a025b..bc3468a025b 100644 --- a/app/assets/javascripts/related_merge_requests/store/state.js +++ b/app/assets/javascripts/issues/related_merge_requests/store/state.js diff --git a/app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue b/app/assets/javascripts/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue index 1530e9a15b5..1530e9a15b5 100644 --- a/app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue +++ b/app/assets/javascripts/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue diff --git a/app/assets/javascripts/sentry_error_stack_trace/index.js b/app/assets/javascripts/issues/sentry_error_stack_trace/index.js index 8e9ee25e7a8..8e9ee25e7a8 100644 --- a/app/assets/javascripts/sentry_error_stack_trace/index.js +++ b/app/assets/javascripts/issues/sentry_error_stack_trace/index.js diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/issues/show.js index 24aa2f0da13..e43e56d7b4e 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/issues/show.js @@ -1,16 +1,15 @@ import loadAwardsHandler from '~/awards_handler'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; -import initIssuableSidebar from '~/init_issuable_sidebar'; -import { IssuableType } from '~/issuable_show/constants'; -import Issue from '~/issue'; -import { initIncidentApp, initIncidentHeaderActions } from '~/issue_show/incident'; -import { initIssuableApp, initIssueHeaderActions } from '~/issue_show/issue'; -import { parseIssuableData } from '~/issue_show/utils/parse_data'; +import { initIssuableHeaderWarnings, initIssuableSidebar } from '~/issuable'; +import { IssuableType } from '~/vue_shared/issuable/show/constants'; +import Issue from '~/issues/issue'; +import { initIncidentApp, initIncidentHeaderActions } from '~/issues/show/incident'; +import { initIssuableApp, initIssueHeaderActions } from '~/issues/show/issue'; +import { parseIssuableData } from '~/issues/show/utils/parse_data'; import initNotesApp from '~/notes'; import { store } from '~/notes/stores'; -import initRelatedMergeRequestsApp from '~/related_merge_requests'; -import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace'; -import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning'; +import initRelatedMergeRequestsApp from '~/issues/related_merge_requests'; +import initSentryErrorStackTraceApp from '~/issues/sentry_error_stack_trace'; import ZenMode from '~/zen_mode'; export default function initShowIssue() { @@ -33,7 +32,7 @@ export default function initShowIssue() { break; } - initIssuableHeaderWarning(store); + initIssuableHeaderWarnings(store); initSentryErrorStackTraceApp(); initRelatedMergeRequestsApp(); diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index d3b58ed3012..eeaf865a35f 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -2,18 +2,11 @@ import { GlIcon, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui'; import Visibility from 'visibilityjs'; import createFlash from '~/flash'; +import { IssuableStatus, IssuableStatusText, IssuableType } from '~/issues/constants'; import Poll from '~/lib/utils/poll'; import { visitUrl } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; -import { - IssuableStatus, - IssuableStatusText, - IssuableType, - IssueTypePath, - IncidentTypePath, - IncidentType, - POLLING_DELAY, -} from '../constants'; +import { IssueTypePath, IncidentTypePath, IncidentType, POLLING_DELAY } from '../constants'; import eventHub from '../event_hub'; import getIssueStateQuery from '../queries/get_issue_state.query.graphql'; import Service from '../services/index'; @@ -296,13 +289,11 @@ export default { window.addEventListener('beforeunload', this.handleBeforeUnloadEvent); - eventHub.$on('delete.issuable', this.deleteIssuable); eventHub.$on('update.issuable', this.updateIssuable); eventHub.$on('close.form', this.closeForm); eventHub.$on('open.form', this.openForm); }, beforeDestroy() { - eventHub.$off('delete.issuable', this.deleteIssuable); eventHub.$off('update.issuable', this.updateIssuable); eventHub.$off('close.form', this.closeForm); eventHub.$off('open.form', this.openForm); @@ -425,25 +416,6 @@ export default { }); }, - deleteIssuable(payload) { - return this.service - .deleteIssuable(payload) - .then((res) => res.data) - .then((data) => { - // Stop the poll so we don't get 404's with the issuable not existing - this.poll.stop(); - - visitUrl(data.web_url); - }) - .catch(() => { - createFlash({ - message: sprintf(__('Error deleting %{issuableType}'), { - issuableType: this.issuableType, - }), - }); - }); - }, - hideStickyHeader() { this.isStickyHeaderShowing = false; }, @@ -482,6 +454,7 @@ export default { <div> <div v-if="canUpdate && showForm"> <form-component + :endpoint="endpoint" :form-state="formState" :initial-description-text="initialDescriptionText" :can-destroy="canDestroy" diff --git a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue new file mode 100644 index 00000000000..26862346b86 --- /dev/null +++ b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue @@ -0,0 +1,71 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { __, sprintf } from '~/locale'; + +export default { + actionCancel: { text: __('Cancel') }, + csrf, + components: { + GlModal, + }, + props: { + issuePath: { + type: String, + required: true, + }, + issueType: { + type: String, + required: true, + }, + modalId: { + type: String, + required: true, + }, + title: { + type: String, + required: true, + }, + }, + computed: { + actionPrimary() { + return { + attributes: { variant: 'danger' }, + text: this.title, + }; + }, + bodyText() { + return this.issueType.toLowerCase() === 'epic' + ? __('Delete this epic and all descendants?') + : sprintf(__('%{issuableType} will be removed! Are you sure?'), { + issuableType: capitalizeFirstCharacter(this.issueType), + }); + }, + }, + methods: { + submitForm() { + this.$emit('delete'); + this.$refs.form.submit(); + }, + }, +}; +</script> + +<template> + <gl-modal + :action-cancel="$options.actionCancel" + :action-primary="actionPrimary" + :modal-id="modalId" + size="sm" + :title="title" + @primary="submitForm" + > + <form ref="form" :action="issuePath" method="post"> + <input type="hidden" name="_method" value="delete" /> + <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> + <input type="hidden" name="destroy_confirm" value="true" /> + {{ bodyText }} + </form> + </gl-modal> +</template> diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index 9dc122d426c..7be4c13f544 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -3,7 +3,7 @@ import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import $ from 'jquery'; import createFlash from '~/flash'; import { __, sprintf } from '~/locale'; -import TaskList from '../../task_list'; +import TaskList from '~/task_list'; import animateMixin from '../mixins/animate'; export default { @@ -133,7 +133,7 @@ export default { } }, }, - safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, + safeHtmlConfig: { ADD_TAGS: ['gl-emoji', 'copy-code'] }, }; </script> diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issues/show/components/edit_actions.vue index 5b7d232fde7..4daf6f2b61b 100644 --- a/app/assets/javascripts/issue_show/components/edit_actions.vue +++ b/app/assets/javascripts/issues/show/components/edit_actions.vue @@ -1,10 +1,12 @@ <script> -import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; +import { GlButton, GlModalDirective } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import { __, sprintf } from '~/locale'; +import Tracking from '~/tracking'; import eventHub from '../event_hub'; import updateMixin from '../mixins/update'; import getIssueStateQuery from '../queries/get_issue_state.query.graphql'; +import DeleteIssueModal from './delete_issue_modal.vue'; const issuableTypes = { issue: __('Issue'), @@ -12,20 +14,26 @@ const issuableTypes = { incident: __('Incident'), }; +const trackingMixin = Tracking.mixin({ label: 'delete_issue' }); + export default { components: { + DeleteIssueModal, GlButton, - GlModal, }, directives: { GlModal: GlModalDirective, }, - mixins: [updateMixin], + mixins: [trackingMixin, updateMixin], props: { canDestroy: { type: Boolean, required: true, }, + endpoint: { + required: true, + type: String, + }, formState: { type: Object, required: true, @@ -65,27 +73,9 @@ export default { issuableType: this.typeToShow.toLowerCase(), }); }, - deleteIssuableModalText() { - return this.issuableType === 'epic' - ? __('Delete this epic and all descendants?') - : sprintf(__('%{issuableType} will be removed! Are you sure?'), { - issuableType: this.typeToShow, - }); - }, isSubmitEnabled() { return this.formState.title.trim() !== ''; }, - modalActionProps() { - return { - primary: { - text: this.deleteIssuableButtonText, - attributes: [{ variant: 'danger' }, { loading: this.deleteLoading }], - }, - cancel: { - text: __('Cancel'), - }, - }; - }, shouldShowDeleteButton() { return this.canDestroy && this.showDeleteButton; }, @@ -101,7 +91,7 @@ export default { }, deleteIssuable() { this.deleteLoading = true; - eventHub.$emit('delete.issuable', { destroy_confirm: true }); + eventHub.$emit('delete.issuable'); }, }, }; @@ -135,22 +125,17 @@ export default { variant="danger" class="qa-delete-button" data-testid="issuable-delete-button" + @click="track('click_button')" > {{ deleteIssuableButtonText }} </gl-button> - <gl-modal - ref="removeModal" + <delete-issue-modal + :issue-path="endpoint" + :issue-type="typeToShow" :modal-id="modalId" - size="sm" - :action-primary="modalActionProps.primary" - :action-cancel="modalActionProps.cancel" - @primary="deleteIssuable" - > - <template #modal-title>{{ deleteIssuableButtonText }}</template> - <div> - <p class="gl-mb-1">{{ deleteIssuableModalText }}</p> - </div> - </gl-modal> + :title="deleteIssuableButtonText" + @delete="deleteIssuable" + /> </div> </div> </template> diff --git a/app/assets/javascripts/issue_show/components/edited.vue b/app/assets/javascripts/issues/show/components/edited.vue index 64f61a1b88e..0da1900a6d0 100644 --- a/app/assets/javascripts/issue_show/components/edited.vue +++ b/app/assets/javascripts/issues/show/components/edited.vue @@ -1,6 +1,6 @@ <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ -import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; +import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; export default { components: { diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue index 5476a1ef897..5476a1ef897 100644 --- a/app/assets/javascripts/issue_show/components/fields/description.vue +++ b/app/assets/javascripts/issues/show/components/fields/description.vue diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issues/show/components/fields/description_template.vue index 35e7860cd9b..9ce49b65a1a 100644 --- a/app/assets/javascripts/issue_show/components/fields/description_template.vue +++ b/app/assets/javascripts/issues/show/components/fields/description_template.vue @@ -1,7 +1,7 @@ <script> import { GlIcon } from '@gitlab/ui'; import $ from 'jquery'; -import IssuableTemplateSelectors from '../../../templates/issuable_template_selectors'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; export default { components: { diff --git a/app/assets/javascripts/issue_show/components/fields/title.vue b/app/assets/javascripts/issues/show/components/fields/title.vue index a73926575d0..a73926575d0 100644 --- a/app/assets/javascripts/issue_show/components/fields/title.vue +++ b/app/assets/javascripts/issues/show/components/fields/title.vue diff --git a/app/assets/javascripts/issue_show/components/fields/type.vue b/app/assets/javascripts/issues/show/components/fields/type.vue index 9110a6924b4..9110a6924b4 100644 --- a/app/assets/javascripts/issue_show/components/fields/type.vue +++ b/app/assets/javascripts/issues/show/components/fields/type.vue diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue index 001e8abb941..6447ec85b4e 100644 --- a/app/assets/javascripts/issue_show/components/form.vue +++ b/app/assets/javascripts/issues/show/components/form.vue @@ -2,7 +2,7 @@ import { GlAlert } from '@gitlab/ui'; import $ from 'jquery'; import Autosave from '~/autosave'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import eventHub from '../event_hub'; import EditActions from './edit_actions.vue'; import DescriptionField from './fields/description.vue'; @@ -26,6 +26,10 @@ export default { type: Boolean, required: true, }, + endpoint: { + type: String, + required: true, + }, formState: { type: Object, required: true, @@ -213,6 +217,7 @@ export default { :enable-autocomplete="enableAutocomplete" /> <edit-actions + :endpoint="endpoint" :form-state="formState" :can-destroy="canDestroy" :show-delete-button="showDeleteButton" diff --git a/app/assets/javascripts/issue_show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index 2c314ce1c3f..700ef92a0f3 100644 --- a/app/assets/javascripts/issue_show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -1,31 +1,38 @@ <script> -import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlModal } from '@gitlab/ui'; +import { + GlButton, + GlDropdown, + GlDropdownDivider, + GlDropdownItem, + GlLink, + GlModal, + GlModalDirective, +} from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import createFlash, { FLASH_TYPES } from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; -import { IssuableType } from '~/issuable_show/constants'; -import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants'; +import { IssuableType } from '~/vue_shared/issuable/show/constants'; +import { IssuableStatus } from '~/issues/constants'; +import { IssueStateEvent } from '~/issues/show/constants'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { visitUrl } from '~/lib/utils/url_utility'; -import { __, sprintf } from '~/locale'; +import { s__, __, sprintf } from '~/locale'; import eventHub from '~/notes/event_hub'; +import Tracking from '~/tracking'; import promoteToEpicMutation from '../queries/promote_to_epic.mutation.graphql'; import updateIssueMutation from '../queries/update_issue.mutation.graphql'; +import DeleteIssueModal from './delete_issue_modal.vue'; + +const trackingMixin = Tracking.mixin({ label: 'delete_issue' }); export default { - components: { - GlButton, - GlDropdown, - GlDropdownItem, - GlLink, - GlModal, - }, actionCancel: { text: __('Cancel'), }, actionPrimary: { text: __('Yes, close issue'), }, + deleteModalId: 'delete-modal-id', i18n: { promoteErrorMessage: __( 'Something went wrong while promoting the issue to an epic. Please try again.', @@ -34,10 +41,26 @@ export default { 'The issue was successfully promoted to an epic. Redirecting to epic...', ), }, + components: { + DeleteIssueModal, + GlButton, + GlDropdown, + GlDropdownDivider, + GlDropdownItem, + GlLink, + GlModal, + }, + directives: { + GlModal: GlModalDirective, + }, + mixins: [trackingMixin], inject: { canCreateIssue: { default: false, }, + canDestroyIssue: { + default: false, + }, canPromoteToEpic: { default: false, }, @@ -56,6 +79,9 @@ export default { isIssueAuthor: { default: false, }, + issuePath: { + default: '', + }, issueType: { default: IssuableType.Issue, }, @@ -78,10 +104,21 @@ export default { isClosed() { return this.openState === IssuableStatus.Closed; }, + issueTypeText() { + const issueTypeTexts = { + [IssuableType.Issue]: s__('HeaderAction|issue'), + [IssuableType.Incident]: s__('HeaderAction|incident'), + }; + + return issueTypeTexts[this.issueType] ?? this.issueType; + }, buttonText() { return this.isClosed - ? sprintf(__('Reopen %{issueType}'), { issueType: this.issueType }) - : sprintf(__('Close %{issueType}'), { issueType: this.issueType }); + ? sprintf(__('Reopen %{issueType}'), { issueType: this.issueTypeText }) + : sprintf(__('Close %{issueType}'), { issueType: this.issueTypeText }); + }, + deleteButtonText() { + return sprintf(__('Delete %{issuableType}'), { issuableType: this.issueTypeText }); }, qaSelector() { return this.isClosed ? 'reopen_issue_button' : 'close_issue_button'; @@ -132,8 +169,7 @@ export default { }) .then(({ data }) => { if (data.updateIssue.errors.length) { - createFlash({ message: data.updateIssue.errors.join('. ') }); - return; + throw new Error(); } const payload = { @@ -166,8 +202,7 @@ export default { }) .then(({ data }) => { if (data.promoteToEpic.errors.length) { - createFlash({ message: data.promoteToEpic.errors.join('; ') }); - return; + throw new Error(); } createFlash({ @@ -219,6 +254,16 @@ export default { > {{ __('Submit as spam') }} </gl-dropdown-item> + <template v-if="canDestroyIssue"> + <gl-dropdown-divider /> + <gl-dropdown-item + v-gl-modal="$options.deleteModalId" + variant="danger" + @click="track('click_dropdown')" + > + {{ deleteButtonText }} + </gl-dropdown-item> + </template> </gl-dropdown> <gl-button @@ -262,6 +307,16 @@ export default { > {{ __('Submit as spam') }} </gl-dropdown-item> + <template v-if="canDestroyIssue"> + <gl-dropdown-divider /> + <gl-dropdown-item + v-gl-modal="$options.deleteModalId" + variant="danger" + @click="track('click_dropdown')" + > + {{ deleteButtonText }} + </gl-dropdown-item> + </template> </gl-dropdown> <gl-modal @@ -279,5 +334,12 @@ export default { </li> </ul> </gl-modal> + + <delete-issue-modal + :issue-path="issuePath" + :issue-type="issueType" + :modal-id="$options.deleteModalId" + :title="deleteButtonText" + /> </div> </template> diff --git a/app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql index 938b90b3f7c..d88633f2ae9 100644 --- a/app/assets/javascripts/issue_show/components/incidents/graphql/queries/get_alert.graphql +++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql @@ -1,5 +1,6 @@ query getAlert($iid: String!, $fullPath: ID!) { project(fullPath: $fullPath) { + id issue(iid: $iid) { id alertManagementAlert { diff --git a/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue b/app/assets/javascripts/issues/show/components/incidents/highlight_bar.vue index 96f187f26dd..d509f0dbc09 100644 --- a/app/assets/javascripts/issue_show/components/incidents/highlight_bar.vue +++ b/app/assets/javascripts/issues/show/components/incidents/highlight_bar.vue @@ -5,7 +5,7 @@ import { formatDate } from '~/lib/utils/datetime_utility'; export default { components: { GlLink, - IncidentSla: () => import('ee_component/issue_show/components/incidents/incident_sla.vue'), + IncidentSla: () => import('ee_component/issues/show/components/incidents/incident_sla.vue'), }, directives: { GlTooltip: GlTooltipDirective, diff --git a/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue index 84107d3eaca..4790062ab7d 100644 --- a/app/assets/javascripts/issue_show/components/incidents/incident_tabs.vue +++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue @@ -16,7 +16,7 @@ export default { GlTab, GlTabs, HighlightBar, - MetricsTab: () => import('ee_component/issue_show/components/incidents/metrics_tab.vue'), + MetricsTab: () => import('ee_component/issues/show/components/incidents/metrics_tab.vue'), }, inject: ['fullPath', 'iid', 'uploadMetricsFeatureAvailable'], apollo: { diff --git a/app/assets/javascripts/issue_show/components/locked_warning.vue b/app/assets/javascripts/issues/show/components/locked_warning.vue index 4b99888ae73..4b99888ae73 100644 --- a/app/assets/javascripts/issue_show/components/locked_warning.vue +++ b/app/assets/javascripts/issues/show/components/locked_warning.vue diff --git a/app/assets/javascripts/issue_show/components/pinned_links.vue b/app/assets/javascripts/issues/show/components/pinned_links.vue index d38189307bd..d38189307bd 100644 --- a/app/assets/javascripts/issue_show/components/pinned_links.vue +++ b/app/assets/javascripts/issues/show/components/pinned_links.vue diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issues/show/components/title.vue index 5e92211685a..5e92211685a 100644 --- a/app/assets/javascripts/issue_show/components/title.vue +++ b/app/assets/javascripts/issues/show/components/title.vue diff --git a/app/assets/javascripts/issue_show/constants.js b/app/assets/javascripts/issues/show/constants.js index ef9699deb42..35f3bcdad70 100644 --- a/app/assets/javascripts/issue_show/constants.js +++ b/app/assets/javascripts/issues/show/constants.js @@ -1,24 +1,5 @@ import { __ } from '~/locale'; -export const IssuableStatus = { - Closed: 'closed', - Open: 'opened', - Reopened: 'reopened', -}; - -export const IssuableStatusText = { - [IssuableStatus.Closed]: __('Closed'), - [IssuableStatus.Open]: __('Open'), - [IssuableStatus.Reopened]: __('Open'), -}; - -export const IssuableType = { - Issue: 'issue', - Epic: 'epic', - MergeRequest: 'merge_request', - Alert: 'alert', -}; - export const IssueStateEvent = { Close: 'CLOSE', Reopen: 'REOPEN', @@ -39,8 +20,3 @@ export const IncidentType = 'incident'; export const issueState = { issueType: undefined, isDirty: false }; export const POLLING_DELAY = 2000; - -export const WorkspaceType = { - project: 'project', - group: 'group', -}; diff --git a/app/assets/javascripts/issuable_show/event_hub.js b/app/assets/javascripts/issues/show/event_hub.js index e31806ad199..e31806ad199 100644 --- a/app/assets/javascripts/issuable_show/event_hub.js +++ b/app/assets/javascripts/issues/show/event_hub.js diff --git a/app/assets/javascripts/issue_show/graphql.js b/app/assets/javascripts/issues/show/graphql.js index 5b8630f7d63..5b8630f7d63 100644 --- a/app/assets/javascripts/issue_show/graphql.js +++ b/app/assets/javascripts/issues/show/graphql.js diff --git a/app/assets/javascripts/issue_show/incident.js b/app/assets/javascripts/issues/show/incident.js index 3aff2d9c54a..a260c31e1da 100644 --- a/app/assets/javascripts/issue_show/incident.js +++ b/app/assets/javascripts/issues/show/incident.js @@ -81,12 +81,14 @@ export function initIncidentHeaderActions(store) { store, provide: { canCreateIssue: parseBoolean(el.dataset.canCreateIncident), + canDestroyIssue: parseBoolean(el.dataset.canDestroyIssue), canPromoteToEpic: parseBoolean(el.dataset.canPromoteToEpic), canReopenIssue: parseBoolean(el.dataset.canReopenIssue), canReportSpam: parseBoolean(el.dataset.canReportSpam), canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue), iid: el.dataset.iid, isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor), + issuePath: el.dataset.issuePath, issueType: el.dataset.issueType, newIssuePath: el.dataset.newIssuePath, projectPath: el.dataset.projectPath, diff --git a/app/assets/javascripts/issue_show/issue.js b/app/assets/javascripts/issues/show/issue.js index 25cc51478ff..60e90934af8 100644 --- a/app/assets/javascripts/issue_show/issue.js +++ b/app/assets/javascripts/issues/show/issue.js @@ -44,6 +44,7 @@ export function initIssuableApp(issuableData, store) { isConfidential: this.getNoteableData?.confidential, isLocked: this.getNoteableData?.discussion_locked, issuableStatus: this.getNoteableData?.state, + id: this.getNoteableData?.id, }, }); }, @@ -65,12 +66,14 @@ export function initIssueHeaderActions(store) { store, provide: { canCreateIssue: parseBoolean(el.dataset.canCreateIssue), + canDestroyIssue: parseBoolean(el.dataset.canDestroyIssue), canPromoteToEpic: parseBoolean(el.dataset.canPromoteToEpic), canReopenIssue: parseBoolean(el.dataset.canReopenIssue), canReportSpam: parseBoolean(el.dataset.canReportSpam), canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue), iid: el.dataset.iid, isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor), + issuePath: el.dataset.issuePath, issueType: el.dataset.issueType, newIssuePath: el.dataset.newIssuePath, projectPath: el.dataset.projectPath, diff --git a/app/assets/javascripts/issue_show/mixins/animate.js b/app/assets/javascripts/issues/show/mixins/animate.js index 4816393da1f..4816393da1f 100644 --- a/app/assets/javascripts/issue_show/mixins/animate.js +++ b/app/assets/javascripts/issues/show/mixins/animate.js diff --git a/app/assets/javascripts/issue_show/mixins/update.js b/app/assets/javascripts/issues/show/mixins/update.js index 72be65b426f..72be65b426f 100644 --- a/app/assets/javascripts/issue_show/mixins/update.js +++ b/app/assets/javascripts/issues/show/mixins/update.js diff --git a/app/assets/javascripts/issue_show/queries/get_issue_state.query.graphql b/app/assets/javascripts/issues/show/queries/get_issue_state.query.graphql index 33b737d2315..33b737d2315 100644 --- a/app/assets/javascripts/issue_show/queries/get_issue_state.query.graphql +++ b/app/assets/javascripts/issues/show/queries/get_issue_state.query.graphql diff --git a/app/assets/javascripts/issue_show/queries/promote_to_epic.mutation.graphql b/app/assets/javascripts/issues/show/queries/promote_to_epic.mutation.graphql index 12d05af0f5e..e3e3a2bc667 100644 --- a/app/assets/javascripts/issue_show/queries/promote_to_epic.mutation.graphql +++ b/app/assets/javascripts/issues/show/queries/promote_to_epic.mutation.graphql @@ -1,6 +1,7 @@ mutation promoteToEpic($input: PromoteToEpicInput!) { promoteToEpic(input: $input) { epic { + id webPath } errors diff --git a/app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql b/app/assets/javascripts/issues/show/queries/update_issue.mutation.graphql index ec8d8f32d8b..ec8d8f32d8b 100644 --- a/app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql +++ b/app/assets/javascripts/issues/show/queries/update_issue.mutation.graphql diff --git a/app/assets/javascripts/issue_show/queries/update_issue_state.mutation.graphql b/app/assets/javascripts/issues/show/queries/update_issue_state.mutation.graphql index d91ca746066..d91ca746066 100644 --- a/app/assets/javascripts/issue_show/queries/update_issue_state.mutation.graphql +++ b/app/assets/javascripts/issues/show/queries/update_issue_state.mutation.graphql diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issues/show/services/index.js index b1deeaae0fc..dba07f623f9 100644 --- a/app/assets/javascripts/issue_show/services/index.js +++ b/app/assets/javascripts/issues/show/services/index.js @@ -1,4 +1,4 @@ -import axios from '../../lib/utils/axios_utils'; +import axios from '~/lib/utils/axios_utils'; export default class Service { constructor(endpoint) { diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issues/show/stores/index.js index a50913d3455..a50913d3455 100644 --- a/app/assets/javascripts/issue_show/stores/index.js +++ b/app/assets/javascripts/issues/show/stores/index.js diff --git a/app/assets/javascripts/issue_show/utils/parse_data.js b/app/assets/javascripts/issues/show/utils/parse_data.js index f1e6bd2419a..f1e6bd2419a 100644 --- a/app/assets/javascripts/issue_show/utils/parse_data.js +++ b/app/assets/javascripts/issues/show/utils/parse_data.js diff --git a/app/assets/javascripts/issue_show/utils/update_description.js b/app/assets/javascripts/issues/show/utils/update_description.js index c5811290e61..c5811290e61 100644 --- a/app/assets/javascripts/issue_show/utils/update_description.js +++ b/app/assets/javascripts/issues/show/utils/update_description.js diff --git a/app/assets/javascripts/issues_list/components/issuable.vue b/app/assets/javascripts/issues_list/components/issuable.vue index 6dc7460b037..6476d5be38c 100644 --- a/app/assets/javascripts/issues_list/components/issuable.vue +++ b/app/assets/javascripts/issues_list/components/issuable.vue @@ -28,7 +28,7 @@ import { convertToCamelCase } from '~/lib/utils/text_utility'; import { mergeUrlParams, setUrlFragment, isExternal } from '~/lib/utils/url_utility'; import { sprintf, __ } from '~/locale'; import initUserPopovers from '~/user_popovers'; -import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; +import IssueAssignees from '~/issuable/components/issue_assignees.vue'; export default { i18n: { diff --git a/app/assets/javascripts/issues_list/components/issuables_list_app.vue b/app/assets/javascripts/issues_list/components/issuables_list_app.vue index 62b52afdaca..71136bf0159 100644 --- a/app/assets/javascripts/issues_list/components/issuables_list_app.vue +++ b/app/assets/javascripts/issues_list/components/issuables_list_app.vue @@ -11,7 +11,7 @@ import axios from '~/lib/utils/axios_utils'; import { scrollToElement, historyPushState } from '~/lib/utils/common_utils'; import { setUrlParams, queryToObject, getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import initManualOrdering from '~/manual_ordering'; +import initManualOrdering from '~/issues/manual_ordering'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import { sortOrderMap, @@ -21,12 +21,12 @@ import { PAGE_SIZE_MANUAL, LOADING_LIST_ITEMS_LENGTH, } from '../constants'; -import issueableEventHub from '../eventhub'; +import issuableEventHub from '../eventhub'; import { emptyStateHelper } from '../service_desk_helper'; import Issuable from './issuable.vue'; /** - * @deprecated Use app/assets/javascripts/issuable_list/components/issuable_list_root.vue instead + * @deprecated Use app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue instead */ export default { LOADING_LIST_ITEMS_LENGTH, @@ -192,7 +192,7 @@ export default { // We need to call nextTick here to wait for all of the boxes to be checked and rendered // before we query the dom in issuable_bulk_update_actions.js. this.$nextTick(() => { - issueableEventHub.$emit('issuables:updateBulkEdit'); + issuableEventHub.$emit('issuables:updateBulkEdit'); }); }, issuables() { @@ -203,7 +203,7 @@ export default { }, mounted() { if (this.canBulkEdit) { - this.unsubscribeToggleBulkEdit = issueableEventHub.$on('issuables:toggleBulkEdit', (val) => { + this.unsubscribeToggleBulkEdit = issuableEventHub.$on('issuables:toggleBulkEdit', (val) => { this.isBulkEditing = val; }); } @@ -211,7 +211,7 @@ export default { }, beforeDestroy() { // eslint-disable-next-line @gitlab/no-global-event-off - issueableEventHub.$off('issuables:toggleBulkEdit'); + issuableEventHub.$off('issuables:toggleBulkEdit'); }, methods: { isSelected(issuableId) { diff --git a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue index 4a2f7861492..aece7372182 100644 --- a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue +++ b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue @@ -7,25 +7,16 @@ import { isInPast, isToday, } from '~/lib/utils/datetime_utility'; -import { convertToCamelCase } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; export default { components: { GlLink, GlIcon, - IssueHealthStatus: () => - import('ee_component/related_items_tree/components/issue_health_status.vue'), - WeightCount: () => import('ee_component/issues/components/weight_count.vue'), }, directives: { GlTooltip: GlTooltipDirective, }, - inject: { - hasIssuableHealthStatusFeature: { - default: false, - }, - }, props: { issue: { type: Object, @@ -54,12 +45,6 @@ export default { timeEstimate() { return this.issue.humanTimeEstimate || this.issue.timeStats?.humanTimeEstimate; }, - showHealthStatus() { - return this.hasIssuableHealthStatusFeature && this.issue.healthStatus; - }, - healthStatus() { - return convertToCamelCase(this.issue.healthStatus); - }, }, methods: { milestoneRemainingTime(dueDate, startDate) { @@ -114,7 +99,6 @@ export default { <gl-icon name="timer" /> {{ timeEstimate }} </span> - <weight-count class="issuable-weight gl-mr-3" :weight="issue.weight" /> - <issue-health-status v-if="showHealthStatus" :health-status="healthStatus" /> + <slot></slot> </span> </template> diff --git a/app/assets/javascripts/issues_list/components/issues_list_app.vue b/app/assets/javascripts/issues_list/components/issues_list_app.vue index 7f2082e5b90..6ced1080b71 100644 --- a/app/assets/javascripts/issues_list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue @@ -8,17 +8,20 @@ import { GlSprintf, GlTooltipDirective, } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import { orderBy } from 'lodash'; import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql'; import getIssuesCountsQuery from 'ee_else_ce/issues_list/queries/get_issues_counts.query.graphql'; -import createFlash from '~/flash'; +import IssueCardTimeInfo from 'ee_else_ce/issues_list/components/issue_card_time_info.vue'; +import createFlash, { FLASH_TYPES } from '~/flash'; import { TYPE_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { ITEM_TYPE } from '~/groups/constants'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import IssuableByEmail from '~/issuable/components/issuable_by_email.vue'; -import IssuableList from '~/issuable_list/components/issuable_list_root.vue'; -import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants'; +import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; +import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants'; import { CREATED_DESC, i18n, @@ -31,14 +34,11 @@ import { TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, TOKEN_TYPE_CONFIDENTIAL, - TOKEN_TYPE_EPIC, - TOKEN_TYPE_ITERATION, TOKEN_TYPE_LABEL, TOKEN_TYPE_MILESTONE, TOKEN_TYPE_MY_REACTION, TOKEN_TYPE_RELEASE, TOKEN_TYPE_TYPE, - TOKEN_TYPE_WEIGHT, UPDATED_DESC, urlSortParams, } from '~/issues_list/constants'; @@ -61,39 +61,29 @@ import { TOKEN_TITLE_ASSIGNEE, TOKEN_TITLE_AUTHOR, TOKEN_TITLE_CONFIDENTIAL, - TOKEN_TITLE_EPIC, - TOKEN_TITLE_ITERATION, TOKEN_TITLE_LABEL, TOKEN_TITLE_MILESTONE, TOKEN_TITLE_MY_REACTION, TOKEN_TITLE_RELEASE, TOKEN_TITLE_TYPE, - TOKEN_TITLE_WEIGHT, } from '~/vue_shared/components/filtered_search_bar/constants'; import eventHub from '../eventhub'; import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql'; -import searchIterationsQuery from '../queries/search_iterations.query.graphql'; import searchLabelsQuery from '../queries/search_labels.query.graphql'; import searchMilestonesQuery from '../queries/search_milestones.query.graphql'; import searchUsersQuery from '../queries/search_users.query.graphql'; -import IssueCardTimeInfo from './issue_card_time_info.vue'; import NewIssueDropdown from './new_issue_dropdown.vue'; const AuthorToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'); const EmojiToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'); -const EpicToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue'); -const IterationToken = () => - import('~/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue'); const LabelToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'); const MilestoneToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'); const ReleaseToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/release_token.vue'); -const WeightToken = () => - import('~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue'); export default { i18n, @@ -109,7 +99,6 @@ export default { IssuableList, IssueCardTimeInfo, NewIssueDropdown, - BlockingIssuesCount: () => import('ee_component/issues/components/blocking_issues_count.vue'), }, directives: { GlTooltip: GlTooltipDirective, @@ -133,9 +122,6 @@ export default { fullPath: { default: '', }, - groupPath: { - default: '', - }, hasAnyIssues: { default: false, }, @@ -148,15 +134,18 @@ export default { hasIssueWeightsFeature: { default: false, }, - hasIterationsFeature: { - default: false, - }, hasMultipleIssueAssigneesFeature: { default: false, }, initialEmail: { default: '', }, + isAnonymousSearchDisabled: { + default: false, + }, + isIssueRepositioningDisabled: { + default: false, + }, isProject: { default: false, }, @@ -182,21 +171,43 @@ export default { default: '', }, }, + props: { + eeSearchTokens: { + type: Array, + required: false, + default: () => [], + }, + }, data() { const state = getParameterByName(PARAM_STATE); - const sortKey = getSortKey(getParameterByName(PARAM_SORT)); const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC; + let sortKey = getSortKey(getParameterByName(PARAM_SORT)) || defaultSortKey; + + if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) { + this.showIssueRepositioningMessage(); + sortKey = defaultSortKey; + } + + const isSearchDisabled = + this.isAnonymousSearchDisabled && + !this.isSignedIn && + window.location.search.includes('search='); + + if (isSearchDisabled) { + this.showAnonymousSearchingMessage(); + } return { dueDateFilter: getDueDateValue(getParameterByName(PARAM_DUE_DATE)), exportCsvPathWithQuery: this.getExportCsvPathWithQuery(), - filterTokens: getFilterTokens(window.location.search), + filterTokens: isSearchDisabled ? [] : getFilterTokens(window.location.search), issues: [], issuesCounts: {}, + issuesError: null, pageInfo: {}, pageParams: getInitialPageParams(sortKey), showBulkEditSidebar: false, - sortKey: sortKey || defaultSortKey, + sortKey, state: state || IssuableStates.Opened, }; }, @@ -214,7 +225,8 @@ export default { this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); }, error(error) { - createFlash({ message: this.$options.i18n.errorFetchingIssues, captureError: true, error }); + this.issuesError = this.$options.i18n.errorFetchingIssues; + Sentry.captureException(error); }, skip() { return !this.hasAnyIssues; @@ -230,7 +242,8 @@ export default { return data[this.namespace] ?? {}; }, error(error) { - createFlash({ message: this.$options.i18n.errorFetchingCounts, captureError: true, error }); + this.issuesError = this.$options.i18n.errorFetchingCounts; + Sentry.captureException(error); }, skip() { return !this.hasAnyIssues; @@ -306,6 +319,7 @@ export default { unique: true, defaultAuthors: [], fetchAuthors: this.fetchUsers, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-author`, preloadedAuthors, }, { @@ -317,6 +331,7 @@ export default { unique: !this.hasMultipleIssueAssigneesFeature, defaultAuthors: DEFAULT_NONE_ANY, fetchAuthors: this.fetchUsers, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-assignee`, preloadedAuthors, }, { @@ -325,6 +340,7 @@ export default { icon: 'clock', token: MilestoneToken, fetchMilestones: this.fetchMilestones, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-milestone`, }, { type: TOKEN_TYPE_LABEL, @@ -333,6 +349,7 @@ export default { token: LabelToken, defaultLabels: DEFAULT_NONE_ANY, fetchLabels: this.fetchLabels, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-label`, }, { type: TOKEN_TYPE_TYPE, @@ -354,6 +371,7 @@ export default { icon: 'rocket', token: ReleaseToken, fetchReleases: this.fetchReleases, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-release`, }); } @@ -365,6 +383,7 @@ export default { token: EmojiToken, unique: true, fetchEmojis: this.fetchEmojis, + recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-my_reaction`, }); tokens.push({ @@ -381,42 +400,13 @@ export default { }); } - if (this.hasIterationsFeature) { - tokens.push({ - type: TOKEN_TYPE_ITERATION, - title: TOKEN_TITLE_ITERATION, - icon: 'iteration', - token: IterationToken, - fetchIterations: this.fetchIterations, - }); + if (this.eeSearchTokens.length) { + tokens.push(...this.eeSearchTokens); } - if (this.groupPath) { - tokens.push({ - type: TOKEN_TYPE_EPIC, - title: TOKEN_TITLE_EPIC, - icon: 'epic', - token: EpicToken, - unique: true, - symbol: '&', - idProperty: 'id', - useIdValue: true, - recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-epic_id`, - fullPath: this.groupPath, - }); - } + tokens.sort((a, b) => a.title.localeCompare(b.title)); - if (this.hasIssueWeightsFeature) { - tokens.push({ - type: TOKEN_TYPE_WEIGHT, - title: TOKEN_TITLE_WEIGHT, - icon: 'weight', - token: WeightToken, - unique: true, - }); - } - - return tokens; + return orderBy(tokens, ['title']); }, showPaginationControls() { return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage); @@ -481,7 +471,12 @@ export default { query: searchLabelsQuery, variables: { fullPath: this.fullPath, search, isProject: this.isProject }, }) - .then(({ data }) => data[this.namespace]?.labels.nodes); + .then(({ data }) => data[this.namespace]?.labels.nodes) + .then((labels) => + // TODO remove once we can search by title-only on the backend + // https://gitlab.com/gitlab-org/gitlab/-/issues/346353 + labels.filter((label) => label.title.toLowerCase().includes(search.toLowerCase())), + ); }, fetchMilestones(search) { return this.$apollo @@ -491,20 +486,6 @@ export default { }) .then(({ data }) => data[this.namespace]?.milestones.nodes); }, - fetchIterations(search) { - const id = Number(search); - const variables = - !search || Number.isNaN(id) - ? { fullPath: this.fullPath, search, isProject: this.isProject } - : { fullPath: this.fullPath, id, isProject: this.isProject }; - - return this.$apollo - .query({ - query: searchIterationsQuery, - variables, - }) - .then(({ data }) => data[this.namespace]?.iterations.nodes); - }, fetchUsers(search) { return this.$apollo .query({ @@ -537,7 +518,7 @@ export default { async handleBulkUpdateClick() { if (!this.hasInitBulkEdit) { const initBulkUpdateSidebar = await import( - '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar' + '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar' ); initBulkUpdateSidebar.default.init('issuable_'); @@ -556,7 +537,14 @@ export default { } this.state = state; }, + handleDismissAlert() { + this.issuesError = null; + }, handleFilter(filter) { + if (this.isAnonymousSearchDisabled && !this.isSignedIn) { + this.showAnonymousSearchingMessage(); + return; + } this.pageParams = getInitialPageParams(this.sortKey); this.filterTokens = filter; }, @@ -607,15 +595,33 @@ export default { }); }) .catch((error) => { - createFlash({ message: this.$options.i18n.reorderError, captureError: true, error }); + this.issuesError = this.$options.i18n.reorderError; + Sentry.captureException(error); }); }, handleSort(sortKey) { + if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) { + this.showIssueRepositioningMessage(); + return; + } + if (this.sortKey !== sortKey) { this.pageParams = getInitialPageParams(sortKey); } this.sortKey = sortKey; }, + showAnonymousSearchingMessage() { + createFlash({ + message: this.$options.i18n.anonymousSearchingMessage, + type: FLASH_TYPES.NOTICE, + }); + }, + showIssueRepositioningMessage() { + createFlash({ + message: this.$options.i18n.issueRepositioningMessage, + type: FLASH_TYPES.NOTICE, + }); + }, toggleBulkEditSidebar(showBulkEditSidebar) { this.showBulkEditSidebar = showBulkEditSidebar; }, @@ -634,6 +640,7 @@ export default { :sort-options="sortOptions" :initial-sort-by="sortKey" :issuables="issues" + :error="issuesError" label-filter-param="label_name" :tabs="$options.IssuableListTabs" :current-tab="state" @@ -647,6 +654,7 @@ export default { :has-previous-page="pageInfo.hasPreviousPage" :url-params="urlParams" @click-tab="handleClickTab" + @dismiss-alert="handleDismissAlert" @filter="handleFilter" @next-page="handleNextPage" @previous-page="handlePreviousPage" @@ -727,12 +735,7 @@ export default { <gl-icon name="thumb-down" /> {{ issuable.downvotes }} </li> - <blocking-issues-count - class="blocking-issues gl-display-none gl-sm-display-block" - :blocking-issues-count="issuable.blockingCount" - :is-list-item="true" - data-testid="blocking-issues" - /> + <slot :issuable="issuable"></slot> </template> <template #empty-state> diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues_list/constants.js index da9b96d0e22..c9eaf0b9908 100644 --- a/app/assets/javascripts/issues_list/constants.js +++ b/app/assets/javascripts/issues_list/constants.js @@ -66,6 +66,7 @@ export const availableSortOptionsJira = [ ]; export const i18n = { + anonymousSearchingMessage: __('You must sign in to search for specific terms.'), calendarLabel: __('Subscribe to calendar'), closed: __('CLOSED'), closedMoved: __('CLOSED (MOVED)'), @@ -75,6 +76,9 @@ export const i18n = { editIssues: __('Edit issues'), errorFetchingCounts: __('An error occurred while getting issue counts'), errorFetchingIssues: __('An error occurred while loading issues'), + issueRepositioningMessage: __( + 'Issues are being rebalanced at the moment, so manual reordering is disabled.', + ), jiraIntegrationMessage: s__( 'JiraService|%{jiraDocsLinkStart}Enable the Jira integration%{jiraDocsLinkEnd} to view your Jira issues in GitLab.', ), @@ -133,6 +137,7 @@ export const DUE_DATE_VALUES = [ DUE_DATE_NEXT_MONTH_AND_PREVIOUS_TWO_WEEKS, ]; +export const BLOCKING_ISSUES_ASC = 'BLOCKING_ISSUES_ASC'; export const BLOCKING_ISSUES_DESC = 'BLOCKING_ISSUES_DESC'; export const CREATED_ASC = 'CREATED_ASC'; export const CREATED_DESC = 'CREATED_DESC'; @@ -154,42 +159,28 @@ export const UPDATED_DESC = 'UPDATED_DESC'; export const WEIGHT_ASC = 'WEIGHT_ASC'; export const WEIGHT_DESC = 'WEIGHT_DESC'; -const PRIORITY_ASC_SORT = 'priority_asc'; -const CREATED_DATE_SORT = 'created_date'; -const CREATED_ASC_SORT = 'created_asc'; -const UPDATED_DESC_SORT = 'updated_desc'; -const UPDATED_ASC_SORT = 'updated_asc'; -const MILESTONE_SORT = 'milestone'; -const MILESTONE_DUE_DESC_SORT = 'milestone_due_desc'; -const DUE_DATE_DESC_SORT = 'due_date_desc'; -const LABEL_PRIORITY_ASC_SORT = 'label_priority_asc'; -const POPULARITY_ASC_SORT = 'popularity_asc'; -const WEIGHT_DESC_SORT = 'weight_desc'; -const BLOCKING_ISSUES_DESC_SORT = 'blocking_issues_desc'; -const TITLE_ASC_SORT = 'title_asc'; -const TITLE_DESC_SORT = 'title_desc'; - export const urlSortParams = { - [PRIORITY_ASC]: PRIORITY_ASC_SORT, - [PRIORITY_DESC]: PRIORITY, - [CREATED_ASC]: CREATED_ASC_SORT, - [CREATED_DESC]: CREATED_DATE_SORT, - [UPDATED_ASC]: UPDATED_ASC_SORT, - [UPDATED_DESC]: UPDATED_DESC_SORT, - [MILESTONE_DUE_ASC]: MILESTONE_SORT, - [MILESTONE_DUE_DESC]: MILESTONE_DUE_DESC_SORT, - [DUE_DATE_ASC]: DUE_DATE, - [DUE_DATE_DESC]: DUE_DATE_DESC_SORT, - [POPULARITY_ASC]: POPULARITY_ASC_SORT, - [POPULARITY_DESC]: POPULARITY, - [LABEL_PRIORITY_ASC]: LABEL_PRIORITY_ASC_SORT, - [LABEL_PRIORITY_DESC]: LABEL_PRIORITY, + [PRIORITY_ASC]: 'priority', + [PRIORITY_DESC]: 'priority_desc', + [CREATED_ASC]: 'created_asc', + [CREATED_DESC]: 'created_date', + [UPDATED_ASC]: 'updated_asc', + [UPDATED_DESC]: 'updated_desc', + [MILESTONE_DUE_ASC]: 'milestone', + [MILESTONE_DUE_DESC]: 'milestone_due_desc', + [DUE_DATE_ASC]: 'due_date', + [DUE_DATE_DESC]: 'due_date_desc', + [POPULARITY_ASC]: 'popularity_asc', + [POPULARITY_DESC]: 'popularity', + [LABEL_PRIORITY_ASC]: 'label_priority', + [LABEL_PRIORITY_DESC]: 'label_priority_desc', [RELATIVE_POSITION_ASC]: RELATIVE_POSITION, - [WEIGHT_ASC]: WEIGHT, - [WEIGHT_DESC]: WEIGHT_DESC_SORT, - [BLOCKING_ISSUES_DESC]: BLOCKING_ISSUES_DESC_SORT, - [TITLE_ASC]: TITLE_ASC_SORT, - [TITLE_DESC]: TITLE_DESC_SORT, + [WEIGHT_ASC]: 'weight', + [WEIGHT_DESC]: 'weight_desc', + [BLOCKING_ISSUES_ASC]: 'blocking_issues_asc', + [BLOCKING_ISSUES_DESC]: 'blocking_issues_desc', + [TITLE_ASC]: 'title_asc', + [TITLE_DESC]: 'title_desc', }; export const MAX_LIST_SIZE = 10; diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js index 59034964afb..9d2ec8b32d2 100644 --- a/app/assets/javascripts/issues_list/index.js +++ b/app/assets/javascripts/issues_list/index.js @@ -2,7 +2,7 @@ import produce from 'immer'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql'; -import IssuesListApp from '~/issues_list/components/issues_list_app.vue'; +import IssuesListApp from 'ee_else_ce/issues_list/components/issues_list_app.vue'; import createDefaultClient from '~/lib/graphql'; import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; import IssuablesListApp from './components/issuables_list_app.vue'; @@ -129,6 +129,8 @@ export function mountIssuesListApp() { hasMultipleIssueAssigneesFeature, importCsvIssuesPath, initialEmail, + isAnonymousSearchDisabled, + isIssueRepositioningDisabled, isProject, isSignedIn, jiraIntegrationPath, @@ -161,6 +163,8 @@ export function mountIssuesListApp() { hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), hasIterationsFeature: parseBoolean(hasIterationsFeature), hasMultipleIssueAssigneesFeature: parseBoolean(hasMultipleIssueAssigneesFeature), + isAnonymousSearchDisabled: parseBoolean(isAnonymousSearchDisabled), + isIssueRepositioningDisabled: parseBoolean(isIssueRepositioningDisabled), isProject: parseBoolean(isProject), isSignedIn: parseBoolean(isSignedIn), jiraIntegrationPath, diff --git a/app/assets/javascripts/issues_list/queries/get_issues.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues.query.graphql index 9866efbcecc..be8deb3fe97 100644 --- a/app/assets/javascripts/issues_list/queries/get_issues.query.graphql +++ b/app/assets/javascripts/issues_list/queries/get_issues.query.graphql @@ -26,6 +26,7 @@ query getIssues( $lastPageSize: Int ) { group(fullPath: $fullPath) @skip(if: $isProject) { + id issues( includeSubgroups: true search: $search @@ -56,6 +57,7 @@ query getIssues( } } project(fullPath: $fullPath) @include(if: $isProject) { + id issues( search: $search sort: $sort diff --git a/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql index 5e755ec5870..1a345fd2877 100644 --- a/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql +++ b/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql @@ -16,6 +16,7 @@ query getIssuesCount( $not: NegatedIssueFilterInput ) { group(fullPath: $fullPath) @skip(if: $isProject) { + id openedIssues: issues( includeSubgroups: true state: opened @@ -69,6 +70,7 @@ query getIssuesCount( } } project(fullPath: $fullPath) @include(if: $isProject) { + id openedIssues: issues( state: opened search: $search diff --git a/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql index 8c95e6114d3..a53dba8c7c8 100644 --- a/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql +++ b/app/assets/javascripts/issues_list/queries/get_issues_list_details.query.graphql @@ -1,9 +1,12 @@ query getIssuesListDetails($fullPath: ID!) { project(fullPath: $fullPath) { + id issues { nodes { + id labels { nodes { + id title color } diff --git a/app/assets/javascripts/issues_list/queries/issue.fragment.graphql b/app/assets/javascripts/issues_list/queries/issue.fragment.graphql index 9c46cb3ef64..07dae3fd756 100644 --- a/app/assets/javascripts/issues_list/queries/issue.fragment.graphql +++ b/app/assets/javascripts/issues_list/queries/issue.fragment.graphql @@ -6,6 +6,7 @@ fragment IssueFragment on Issue { createdAt downvotes dueDate + hidden humanTimeEstimate mergeRequestsCount moved diff --git a/app/assets/javascripts/issues_list/queries/iteration.fragment.graphql b/app/assets/javascripts/issues_list/queries/iteration.fragment.graphql deleted file mode 100644 index 4f7217be7f7..00000000000 --- a/app/assets/javascripts/issues_list/queries/iteration.fragment.graphql +++ /dev/null @@ -1,10 +0,0 @@ -fragment Iteration on Iteration { - id - title - startDate - dueDate - iterationCadence { - id - title - } -} diff --git a/app/assets/javascripts/issues_list/queries/search_iterations.query.graphql b/app/assets/javascripts/issues_list/queries/search_iterations.query.graphql deleted file mode 100644 index 93600c62905..00000000000 --- a/app/assets/javascripts/issues_list/queries/search_iterations.query.graphql +++ /dev/null @@ -1,18 +0,0 @@ -#import "./iteration.fragment.graphql" - -query searchIterations($fullPath: ID!, $search: String, $id: ID, $isProject: Boolean = false) { - group(fullPath: $fullPath) @skip(if: $isProject) { - iterations(title: $search, id: $id, includeAncestors: true) { - nodes { - ...Iteration - } - } - } - project(fullPath: $fullPath) @include(if: $isProject) { - iterations(title: $search, id: $id, includeAncestors: true) { - nodes { - ...Iteration - } - } - } -} diff --git a/app/assets/javascripts/issues_list/queries/search_labels.query.graphql b/app/assets/javascripts/issues_list/queries/search_labels.query.graphql index 1515bd91da3..44b57317161 100644 --- a/app/assets/javascripts/issues_list/queries/search_labels.query.graphql +++ b/app/assets/javascripts/issues_list/queries/search_labels.query.graphql @@ -2,6 +2,7 @@ query searchLabels($fullPath: ID!, $search: String, $isProject: Boolean = false) { group(fullPath: $fullPath) @skip(if: $isProject) { + id labels(searchTerm: $search, includeAncestorGroups: true, includeDescendantGroups: true) { nodes { ...Label @@ -9,6 +10,7 @@ query searchLabels($fullPath: ID!, $search: String, $isProject: Boolean = false) } } project(fullPath: $fullPath) @include(if: $isProject) { + id labels(searchTerm: $search, includeAncestorGroups: true) { nodes { ...Label diff --git a/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql b/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql index 8c6c50e9dc2..e7eb08104a6 100644 --- a/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql +++ b/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql @@ -2,6 +2,7 @@ query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = false) { group(fullPath: $fullPath) @skip(if: $isProject) { + id milestones(searchTitle: $search, includeAncestors: true, includeDescendants: true) { nodes { ...Milestone @@ -9,6 +10,7 @@ query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = fa } } project(fullPath: $fullPath) @include(if: $isProject) { + id milestones(searchTitle: $search, includeAncestors: true) { nodes { ...Milestone diff --git a/app/assets/javascripts/issues_list/queries/search_projects.query.graphql b/app/assets/javascripts/issues_list/queries/search_projects.query.graphql index 75463f643a2..bd2f9bc2340 100644 --- a/app/assets/javascripts/issues_list/queries/search_projects.query.graphql +++ b/app/assets/javascripts/issues_list/queries/search_projects.query.graphql @@ -1,5 +1,6 @@ query searchProjects($fullPath: ID!, $search: String) { group(fullPath: $fullPath) { + id projects(search: $search, includeSubgroups: true) { nodes { id diff --git a/app/assets/javascripts/issues_list/queries/search_users.query.graphql b/app/assets/javascripts/issues_list/queries/search_users.query.graphql index 0211fc66235..92517ad35d0 100644 --- a/app/assets/javascripts/issues_list/queries/search_users.query.graphql +++ b/app/assets/javascripts/issues_list/queries/search_users.query.graphql @@ -2,8 +2,10 @@ query searchUsers($fullPath: ID!, $search: String, $isProject: Boolean = false) { group(fullPath: $fullPath) @skip(if: $isProject) { + id groupMembers(search: $search) { nodes { + id user { ...User } @@ -11,8 +13,10 @@ query searchUsers($fullPath: ID!, $search: String, $isProject: Boolean = false) } } project(fullPath: $fullPath) @include(if: $isProject) { + id projectMembers(search: $search) { nodes { + id user { ...User } diff --git a/app/assets/javascripts/issues_list/utils.js b/app/assets/javascripts/issues_list/utils.js index 0e57e2bff83..99946e4e851 100644 --- a/app/assets/javascripts/issues_list/utils.js +++ b/app/assets/javascripts/issues_list/utils.js @@ -1,5 +1,6 @@ import { API_PARAM, + BLOCKING_ISSUES_ASC, BLOCKING_ISSUES_DESC, CREATED_ASC, CREATED_DESC, @@ -143,7 +144,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) id: sortOptions.length + 1, title: __('Blocking'), sortDirection: { - ascending: BLOCKING_ISSUES_DESC, + ascending: BLOCKING_ISSUES_ASC, descending: BLOCKING_ISSUES_DESC, }, }); diff --git a/app/assets/javascripts/jira_connect/branches/graphql/queries/get_project.query.graphql b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_project.query.graphql index f3428e816d7..df72a1ca6e6 100644 --- a/app/assets/javascripts/jira_connect/branches/graphql/queries/get_project.query.graphql +++ b/app/assets/javascripts/jira_connect/branches/graphql/queries/get_project.query.graphql @@ -5,6 +5,7 @@ query getProject( $branchNamesSearchPattern: String! ) { project(fullPath: $projectPath) { + id repository { branchNames( limit: $branchNamesLimit diff --git a/app/assets/javascripts/jira_connect/branches/index.js b/app/assets/javascripts/jira_connect/branches/index.js index 04510fcff4b..a9a56a6362e 100644 --- a/app/assets/javascripts/jira_connect/branches/index.js +++ b/app/assets/javascripts/jira_connect/branches/index.js @@ -5,7 +5,7 @@ import createDefaultClient from '~/lib/graphql'; Vue.use(VueApollo); -export default async function initJiraConnectBranches() { +export default function initJiraConnectBranches() { const el = document.querySelector('.js-jira-connect-create-branch'); if (!el) { return null; diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue index c0504cbb645..7fd4cc38f11 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue @@ -7,6 +7,7 @@ import { SET_ALERT } from '../store/mutation_types'; import SubscriptionsList from './subscriptions_list.vue'; import AddNamespaceButton from './add_namespace_button.vue'; import SignInButton from './sign_in_button.vue'; +import UserLink from './user_link.vue'; export default { name: 'JiraConnectApp', @@ -18,6 +19,7 @@ export default { SubscriptionsList, AddNamespaceButton, SignInButton, + UserLink, }, inject: { usersPath: { @@ -74,6 +76,8 @@ export default { </template> </gl-alert> + <user-link :user-signed-in="userSignedIn" :has-subscriptions="hasSubscriptions" /> + <h2 class="gl-text-center gl-mb-7">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2> <div class="jira-connect-app-body gl-mx-auto gl-px-5 gl-mb-7"> <template v-if="hasSubscriptions"> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue b/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue new file mode 100644 index 00000000000..fad3d2616d8 --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue @@ -0,0 +1,67 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils'; + +export default { + components: { + GlLink, + GlSprintf, + }, + inject: { + usersPath: { + default: '', + }, + gitlabUserPath: { + default: '', + }, + }, + props: { + userSignedIn: { + type: Boolean, + required: true, + }, + hasSubscriptions: { + type: Boolean, + required: true, + }, + }, + data() { + return { + signInURL: '', + }; + }, + computed: { + gitlabUserHandle() { + return `@${gon.current_username}`; + }, + }, + async created() { + this.signInURL = await getGitlabSignInURL(this.usersPath); + }, + i18n: { + signInText: __('Sign in to GitLab'), + signedInAsUserText: __('Signed in to GitLab as %{user_link}'), + }, +}; +</script> +<template> + <div class="jira-connect-user gl-font-base"> + <gl-sprintf v-if="userSignedIn" :message="$options.i18n.signedInAsUserText"> + <template #user_link> + <gl-link data-testid="gitlab-user-link" :href="gitlabUserPath" target="_blank"> + {{ gitlabUserHandle }} + </gl-link> + </template> + </gl-sprintf> + + <gl-link + v-else-if="hasSubscriptions" + data-testid="sign-in-link" + :href="signInURL" + target="_blank" + > + {{ $options.i18n.signInText }} + </gl-link> + </div> +</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/index.js b/app/assets/javascripts/jira_connect/subscriptions/index.js index 8a7a80d885d..cd1fc1d4455 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/index.js +++ b/app/assets/javascripts/jira_connect/subscriptions/index.js @@ -7,25 +7,11 @@ import Translate from '~/vue_shared/translate'; import JiraConnectApp from './components/app.vue'; import createStore from './store'; -import { getGitlabSignInURL, sizeToParent } from './utils'; +import { sizeToParent } from './utils'; const store = createStore(); -/** - * Add `return_to` query param to all HAML-defined GitLab sign in links. - */ -const updateSignInLinks = async () => { - await Promise.all( - Array.from(document.querySelectorAll('.js-jira-connect-sign-in')).map(async (el) => { - const updatedLink = await getGitlabSignInURL(el.getAttribute('href')); - el.setAttribute('href', updatedLink); - }), - ); -}; - -export async function initJiraConnect() { - await updateSignInLinks(); - +export function initJiraConnect() { const el = document.querySelector('.js-jira-connect-app'); if (!el) { return null; @@ -35,7 +21,7 @@ export async function initJiraConnect() { Vue.use(Translate); Vue.use(GlFeatureFlagsPlugin); - const { groupsPath, subscriptions, subscriptionsPath, usersPath } = el.dataset; + const { groupsPath, subscriptions, subscriptionsPath, usersPath, gitlabUserPath } = el.dataset; sizeToParent(); return new Vue({ @@ -46,6 +32,7 @@ export async function initJiraConnect() { subscriptions: JSON.parse(subscriptions), subscriptionsPath, usersPath, + gitlabUserPath, }, render(createElement) { return createElement(JiraConnectApp); diff --git a/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql index 6fec07cc6f8..4c26399e16b 100644 --- a/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql +++ b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql @@ -2,6 +2,7 @@ query getJiraImportDetails($fullPath: ID!) { project(fullPath: $fullPath) { + id jiraImportStatus jiraImports { nodes { diff --git a/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql b/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql index fde2ebeff91..fe797879d07 100644 --- a/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql +++ b/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql @@ -2,6 +2,7 @@ fragment JiraImport on JiraImport { jiraProjectKey scheduledAt scheduledBy { + id name } } diff --git a/app/assets/javascripts/jira_import/queries/search_project_members.query.graphql b/app/assets/javascripts/jira_import/queries/search_project_members.query.graphql index 6ea8963e6a6..7666fa3bd97 100644 --- a/app/assets/javascripts/jira_import/queries/search_project_members.query.graphql +++ b/app/assets/javascripts/jira_import/queries/search_project_members.query.graphql @@ -1,7 +1,9 @@ query jiraSearchProjectMembers($fullPath: ID!, $search: String) { project(fullPath: $fullPath) { + id projectMembers(search: $search) { nodes { + id user { id name diff --git a/app/assets/javascripts/jobs/bridge/app.vue b/app/assets/javascripts/jobs/bridge/app.vue new file mode 100644 index 00000000000..67c22712776 --- /dev/null +++ b/app/assets/javascripts/jobs/bridge/app.vue @@ -0,0 +1,20 @@ +<script> +import BridgeEmptyState from './components/empty_state.vue'; +import BridgeSidebar from './components/sidebar.vue'; + +export default { + name: 'BridgePageApp', + components: { + BridgeEmptyState, + BridgeSidebar, + }, +}; +</script> +<template> + <div> + <!-- TODO: get job details and show CI header --> + <!-- TODO: add downstream pipeline path --> + <bridge-empty-state downstream-pipeline-path="#" /> + <bridge-sidebar /> + </div> +</template> diff --git a/app/assets/javascripts/jobs/bridge/components/constants.js b/app/assets/javascripts/jobs/bridge/components/constants.js new file mode 100644 index 00000000000..33310b3157a --- /dev/null +++ b/app/assets/javascripts/jobs/bridge/components/constants.js @@ -0,0 +1 @@ +export const SIDEBAR_COLLAPSE_BREAKPOINTS = ['xs', 'sm']; diff --git a/app/assets/javascripts/jobs/bridge/components/empty_state.vue b/app/assets/javascripts/jobs/bridge/components/empty_state.vue new file mode 100644 index 00000000000..bd07d863719 --- /dev/null +++ b/app/assets/javascripts/jobs/bridge/components/empty_state.vue @@ -0,0 +1,45 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + name: 'BridgeEmptyState', + i18n: { + title: __('This job triggers a downstream pipeline'), + linkBtnText: __('View downstream pipeline'), + }, + components: { + GlButton, + }, + inject: { + emptyStateIllustrationPath: { + type: String, + require: true, + }, + }, + props: { + downstreamPipelinePath: { + type: String, + required: false, + default: undefined, + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11"> + <img :src="emptyStateIllustrationPath" /> + <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1> + <gl-button + v-if="downstreamPipelinePath" + class="gl-mt-3" + category="secondary" + variant="confirm" + size="medium" + :href="downstreamPipelinePath" + > + {{ $options.i18n.linkBtnText }} + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/jobs/bridge/components/sidebar.vue b/app/assets/javascripts/jobs/bridge/components/sidebar.vue new file mode 100644 index 00000000000..68b767408f0 --- /dev/null +++ b/app/assets/javascripts/jobs/bridge/components/sidebar.vue @@ -0,0 +1,98 @@ +<script> +import { GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; +import { __ } from '~/locale'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; +import { JOB_SIDEBAR } from '../../constants'; +import { SIDEBAR_COLLAPSE_BREAKPOINTS } from './constants'; + +export default { + styles: { + top: '75px', + width: '290px', + }, + name: 'BridgeSidebar', + i18n: { + ...JOB_SIDEBAR, + retryButton: __('Retry'), + retryTriggerJob: __('Retry the trigger job'), + retryDownstreamPipeline: __('Retry the downstream pipeline'), + }, + borderTopClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100'], + components: { + GlButton, + GlDropdown, + GlDropdownItem, + TooltipOnTruncate, + }, + inject: { + buildName: { + type: String, + default: '', + }, + }, + data() { + return { + isSidebarExpanded: true, + }; + }, + created() { + window.addEventListener('resize', this.onResize); + }, + mounted() { + this.onResize(); + }, + methods: { + toggleSidebar() { + this.isSidebarExpanded = !this.isSidebarExpanded; + }, + onResize() { + const breakpoint = bp.getBreakpointSize(); + if (SIDEBAR_COLLAPSE_BREAKPOINTS.includes(breakpoint)) { + this.isSidebarExpanded = false; + } else if (!this.isSidebarExpanded) { + this.isSidebarExpanded = true; + } + }, + }, +}; +</script> +<template> + <aside + class="gl-fixed gl-right-0 gl-px-5 gl-bg-gray-10 gl-h-full gl-border-l-solid gl-border-1 gl-border-gray-100 gl-z-index-200 gl-overflow-hidden" + :style="this.$options.styles" + :class="{ + 'gl-display-none': !isSidebarExpanded, + }" + > + <div class="gl-py-5 gl-display-flex gl-align-items-center"> + <tooltip-on-truncate :title="buildName" truncate-target="child" + ><h4 class="gl-mb-0 gl-mr-2 gl-text-truncate"> + {{ buildName }} + </h4> + </tooltip-on-truncate> + <!-- TODO: implement retry actions --> + <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right"> + <gl-dropdown + :text="$options.i18n.retryButton" + category="primary" + variant="confirm" + right + size="medium" + > + <gl-dropdown-item>{{ $options.i18n.retryTriggerJob }}</gl-dropdown-item> + <gl-dropdown-item>{{ $options.i18n.retryDownstreamPipeline }}</gl-dropdown-item> + </gl-dropdown> + </div> + <gl-button + :aria-label="$options.i18n.toggleSidebar" + data-testid="sidebar-expansion-toggle" + category="tertiary" + class="gl-md-display-none gl-ml-2" + icon="chevron-double-lg-right" + @click="toggleSidebar" + /> + </div> + <!-- TODO: get job details and show commit block, stage dropdown, jobs list --> + </aside> +</template> diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue index 6105299e15c..97141a27a5e 100644 --- a/app/assets/javascripts/jobs/components/job_log_controllers.vue +++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue @@ -5,7 +5,7 @@ import { __, s__, sprintf } from '~/locale'; export default { i18n: { - eraseLogButtonLabel: s__('Job|Erase job log'), + eraseLogButtonLabel: s__('Job|Erase job log and artifacts'), scrollToBottomButtonLabel: s__('Job|Scroll to bottom'), scrollToTopButtonLabel: s__('Job|Scroll to top'), showRawButtonLabel: s__('Job|Show complete raw'), diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue index 1b50006239c..9aa1503c7c3 100644 --- a/app/assets/javascripts/jobs/components/sidebar.vue +++ b/app/assets/javascripts/jobs/components/sidebar.vue @@ -2,7 +2,7 @@ import { GlButton, GlIcon } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { JOB_SIDEBAR } from '../constants'; import ArtifactsBlock from './artifacts_block.vue'; import CommitBlock from './commit_block.vue'; diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue index d90377029c5..5451cd21c14 100644 --- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue +++ b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue @@ -20,6 +20,9 @@ export default { duration() { return timeIntervalInWords(this.job.duration); }, + durationTitle() { + return this.job.finished_at ? __('Duration') : __('Elapsed time'); + }, erasedAt() { return this.timeFormatted(this.job.erased_at); }, @@ -76,7 +79,7 @@ export default { <template> <div v-if="shouldRenderBlock"> - <detail-row v-if="job.duration" :value="duration" title="Duration" /> + <detail-row v-if="job.duration" :value="duration" :title="durationTitle" /> <detail-row v-if="job.finished_at" :value="finishedAt" diff --git a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue index 51251c0cacc..7dfa963a857 100644 --- a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue +++ b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue @@ -12,6 +12,7 @@ import { JOB_SCHEDULED, PLAY_JOB_CONFIRMATION_MESSAGE, RUN_JOB_NOW_HEADER_TITLE, + FILE_TYPE_ARCHIVE, } from '../constants'; import eventHub from '../event_hub'; import cancelJobMutation from '../graphql/mutations/job_cancel.mutation.graphql'; @@ -58,12 +59,21 @@ export default { }, }, computed: { + hasArtifacts() { + return this.job.artifacts.nodes.find((artifact) => artifact.fileType === FILE_TYPE_ARCHIVE); + }, artifactDownloadPath() { - return this.job.artifacts?.nodes[0]?.downloadPath; + return this.hasArtifacts.downloadPath; }, canReadJob() { return this.job.userPermissions?.readBuild; }, + canUpdateJob() { + return this.job.userPermissions?.updateBuild; + }, + canReadArtifacts() { + return this.job.userPermissions?.readJobArtifacts; + }, isActive() { return this.job.active; }, @@ -86,7 +96,7 @@ export default { return this.job.detailedStatus?.action?.method; }, shouldDisplayArtifacts() { - return this.job.userPermissions?.readJobArtifacts && this.job.artifacts?.nodes.length > 0; + return this.canReadArtifacts && this.hasArtifacts; }, }, methods: { @@ -139,7 +149,7 @@ export default { <template> <gl-button-group> - <template v-if="canReadJob"> + <template v-if="canReadJob && canUpdateJob"> <gl-button v-if="isActive" data-testid="cancel-button" diff --git a/app/assets/javascripts/jobs/components/table/cells/pipeline_cell.vue b/app/assets/javascripts/jobs/components/table/cells/pipeline_cell.vue index 71f9397f5f5..1a6d1a341b0 100644 --- a/app/assets/javascripts/jobs/components/table/cells/pipeline_cell.vue +++ b/app/assets/javascripts/jobs/components/table/cells/pipeline_cell.vue @@ -35,10 +35,12 @@ export default { </script> <template> - <div class="gl-text-truncate"> - <gl-link class="gl-text-gray-500!" :href="pipelinePath" data-testid="pipeline-id"> - {{ pipelineId }} - </gl-link> + <div> + <div class="gl-text-truncate"> + <gl-link class="gl-text-gray-500!" :href="pipelinePath" data-testid="pipeline-id"> + {{ pipelineId }} + </gl-link> + </div> <div> <span>{{ __('created by') }}</span> <gl-link v-if="showAvatar" :href="userPath" data-testid="pipeline-user-link"> diff --git a/app/assets/javascripts/jobs/components/table/constants.js b/app/assets/javascripts/jobs/components/table/constants.js index e5d1bc01cbf..962979ba573 100644 --- a/app/assets/javascripts/jobs/components/table/constants.js +++ b/app/assets/javascripts/jobs/components/table/constants.js @@ -1,4 +1,5 @@ import { s__, __ } from '~/locale'; +import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants'; export const GRAPHQL_PAGE_SIZE = 30; @@ -17,6 +18,9 @@ export const DEFAULT = 'default'; /* Job Status Constants */ export const JOB_SCHEDULED = 'SCHEDULED'; +/* Artifact file types */ +export const FILE_TYPE_ARCHIVE = 'ARCHIVE'; + /* i18n */ export const ACTIONS_DOWNLOAD_ARTIFACTS = __('Download artifacts'); export const ACTIONS_START_NOW = s__('DelayedJobs|Start now'); @@ -30,3 +34,66 @@ export const PLAY_JOB_CONFIRMATION_MESSAGE = s__( `DelayedJobs|Are you sure you want to run %{job_name} immediately? This job will run automatically after its timer finishes.`, ); export const RUN_JOB_NOW_HEADER_TITLE = s__('DelayedJobs|Run the delayed job now?'); + +/* Table constants */ + +const defaultTableClasses = { + tdClass: 'gl-p-5!', + thClass: DEFAULT_TH_CLASSES, +}; +// eslint-disable-next-line @gitlab/require-i18n-strings +const coverageTdClasses = `${defaultTableClasses.tdClass} gl-display-none! gl-lg-display-table-cell!`; + +export const DEFAULT_FIELDS = [ + { + key: 'status', + label: __('Status'), + ...defaultTableClasses, + columnClass: 'gl-w-10p', + }, + { + key: 'job', + label: __('Job'), + ...defaultTableClasses, + columnClass: 'gl-w-20p', + }, + { + key: 'pipeline', + label: __('Pipeline'), + ...defaultTableClasses, + columnClass: 'gl-w-10p', + }, + { + key: 'stage', + label: __('Stage'), + ...defaultTableClasses, + columnClass: 'gl-w-10p', + }, + { + key: 'name', + label: __('Name'), + ...defaultTableClasses, + columnClass: 'gl-w-15p', + }, + { + key: 'duration', + label: __('Duration'), + ...defaultTableClasses, + columnClass: 'gl-w-15p', + }, + { + key: 'coverage', + label: __('Coverage'), + tdClass: coverageTdClasses, + thClass: defaultTableClasses.thClass, + columnClass: 'gl-w-10p', + }, + { + key: 'actions', + label: '', + ...defaultTableClasses, + columnClass: 'gl-w-10p', + }, +]; + +export const JOBS_TAB_FIELDS = DEFAULT_FIELDS.filter((field) => field.key !== 'pipeline'); diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql index c8763d4767e..88937185a8c 100644 --- a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql +++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql @@ -7,6 +7,7 @@ query getJobs( $statuses: [CiJobStatus!] ) { project(fullPath: $fullPath) { + id jobs(after: $after, before: $before, first: $first, last: $last, statuses: $statuses) { pageInfo { endCursor @@ -18,6 +19,7 @@ query getJobs( artifacts { nodes { downloadPath + fileType } } allowFailure @@ -27,6 +29,7 @@ query getJobs( triggered createdByTag detailedStatus { + id detailsPath group icon @@ -34,6 +37,7 @@ query getJobs( text tooltip action { + id buttonTitle icon method @@ -51,11 +55,13 @@ query getJobs( id path user { + id webPath avatarUrl } } stage { + id name } name @@ -70,6 +76,7 @@ query getJobs( userPermissions { readBuild readJobArtifacts + updateBuild } } } diff --git a/app/assets/javascripts/jobs/components/table/jobs_table.vue b/app/assets/javascripts/jobs/components/table/jobs_table.vue index 298c99c4162..f513d2090fa 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table.vue @@ -1,75 +1,17 @@ <script> import { GlTable } from '@gitlab/ui'; -import { DEFAULT_TH_CLASSES } from '~/lib/utils/constants'; -import { s__, __ } from '~/locale'; +import { s__ } from '~/locale'; import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; import ActionsCell from './cells/actions_cell.vue'; import DurationCell from './cells/duration_cell.vue'; import JobCell from './cells/job_cell.vue'; import PipelineCell from './cells/pipeline_cell.vue'; - -const defaultTableClasses = { - tdClass: 'gl-p-5!', - thClass: DEFAULT_TH_CLASSES, -}; -// eslint-disable-next-line @gitlab/require-i18n-strings -const coverageTdClasses = `${defaultTableClasses.tdClass} gl-display-none! gl-lg-display-table-cell!`; +import { DEFAULT_FIELDS } from './constants'; export default { i18n: { emptyText: s__('Jobs|No jobs to show'), }, - fields: [ - { - key: 'status', - label: __('Status'), - ...defaultTableClasses, - columnClass: 'gl-w-10p', - }, - { - key: 'job', - label: __('Job'), - ...defaultTableClasses, - columnClass: 'gl-w-20p', - }, - { - key: 'pipeline', - label: __('Pipeline'), - ...defaultTableClasses, - columnClass: 'gl-w-10p', - }, - { - key: 'stage', - label: __('Stage'), - ...defaultTableClasses, - columnClass: 'gl-w-10p', - }, - { - key: 'name', - label: __('Name'), - ...defaultTableClasses, - columnClass: 'gl-w-15p', - }, - { - key: 'duration', - label: __('Duration'), - ...defaultTableClasses, - columnClass: 'gl-w-15p', - }, - { - key: 'coverage', - label: __('Coverage'), - tdClass: coverageTdClasses, - thClass: defaultTableClasses.thClass, - columnClass: 'gl-w-10p', - }, - { - key: 'actions', - label: '', - ...defaultTableClasses, - columnClass: 'gl-w-10p', - }, - ], components: { ActionsCell, CiBadge, @@ -83,6 +25,11 @@ export default { type: Array, required: true, }, + tableFields: { + type: Array, + required: false, + default: () => DEFAULT_FIELDS, + }, }, methods: { formatCoverage(coverage) { @@ -95,7 +42,7 @@ export default { <template> <gl-table :items="jobs" - :fields="$options.fields" + :fields="tableFields" :tbody-tr-attr="{ 'data-testid': 'jobs-table-row' }" :empty-text="$options.i18n.emptyText" show-empty diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js index 1fb6a6f9850..e078a6c2319 100644 --- a/app/assets/javascripts/jobs/index.js +++ b/app/assets/javascripts/jobs/index.js @@ -1,10 +1,11 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import BridgeApp from './bridge/app.vue'; import JobApp from './components/job_app.vue'; import createStore from './store'; -export default () => { - const element = document.getElementById('js-job-vue-app'); - +const initializeJobPage = (element) => { const store = createStore(); // Let's start initializing the store (i.e. fetching data) right away @@ -51,3 +52,35 @@ export default () => { }, }); }; + +const initializeBridgePage = (el) => { + const { buildName, emptyStateIllustrationPath } = el.dataset; + + Vue.use(VueApollo); + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + return new Vue({ + el, + apolloProvider, + provide: { + buildName, + emptyStateIllustrationPath, + }, + render(h) { + return h(BridgeApp); + }, + }); +}; + +export default () => { + const jobElement = document.getElementById('js-job-page'); + const bridgeElement = document.getElementById('js-bridge-page'); + + if (jobElement) { + initializeJobPage(jobElement); + } else { + initializeBridgePage(bridgeElement); + } +}; diff --git a/app/assets/javascripts/vue_shared/components/delete_label_modal.vue b/app/assets/javascripts/labels/components/delete_label_modal.vue index 1ff0938d086..1ff0938d086 100644 --- a/app/assets/javascripts/vue_shared/components/delete_label_modal.vue +++ b/app/assets/javascripts/labels/components/delete_label_modal.vue diff --git a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue b/app/assets/javascripts/labels/components/promote_label_modal.vue index e708cd32fff..e708cd32fff 100644 --- a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue +++ b/app/assets/javascripts/labels/components/promote_label_modal.vue diff --git a/app/assets/javascripts/create_label.js b/app/assets/javascripts/labels/create_label_dropdown.js index 07fe2c7e01f..8c166158a44 100644 --- a/app/assets/javascripts/create_label.js +++ b/app/assets/javascripts/labels/create_label_dropdown.js @@ -1,8 +1,8 @@ /* eslint-disable func-names */ import $ from 'jquery'; -import Api from './api'; -import { humanize } from './lib/utils/text_utility'; +import Api from '~/api'; +import { humanize } from '~/lib/utils/text_utility'; export default class CreateLabelDropdown { constructor($el, namespacePath, projectPath) { diff --git a/app/assets/javascripts/issue_show/event_hub.js b/app/assets/javascripts/labels/event_hub.js index e31806ad199..e31806ad199 100644 --- a/app/assets/javascripts/issue_show/event_hub.js +++ b/app/assets/javascripts/labels/event_hub.js diff --git a/app/assets/javascripts/group_label_subscription.js b/app/assets/javascripts/labels/group_label_subscription.js index 378259eb9c8..ea69e6585e6 100644 --- a/app/assets/javascripts/group_label_subscription.js +++ b/app/assets/javascripts/labels/group_label_subscription.js @@ -1,8 +1,8 @@ import $ from 'jquery'; import { __ } from '~/locale'; import { fixTitle, hide } from '~/tooltips'; -import createFlash from './flash'; -import axios from './lib/utils/axios_utils'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; const tooltipTitles = { group: __('Unsubscribe at group level'), diff --git a/app/assets/javascripts/labels/index.js b/app/assets/javascripts/labels/index.js new file mode 100644 index 00000000000..22a9c0a89c0 --- /dev/null +++ b/app/assets/javascripts/labels/index.js @@ -0,0 +1,137 @@ +import $ from 'jquery'; +import Vue from 'vue'; +import { BV_SHOW_MODAL } from '~/lib/utils/constants'; +import Translate from '~/vue_shared/translate'; +import DeleteLabelModal from './components/delete_label_modal.vue'; +import PromoteLabelModal from './components/promote_label_modal.vue'; +import eventHub from './event_hub'; +import GroupLabelSubscription from './group_label_subscription'; +import LabelManager from './label_manager'; +import ProjectLabelSubscription from './project_label_subscription'; + +export function initDeleteLabelModal(optionalProps = {}) { + new Vue({ + render(h) { + return h(DeleteLabelModal, { + props: { + selector: '.js-delete-label-modal-button', + ...optionalProps, + }, + }); + }, + }).$mount(); +} + +export function initLabels() { + if ($('.prioritized-labels').length) { + new LabelManager(); // eslint-disable-line no-new + } + $('.label-subscription').each((i, el) => { + const $el = $(el); + + if ($el.find('.dropdown-group-label').length) { + new GroupLabelSubscription($el); // eslint-disable-line no-new + } else { + new ProjectLabelSubscription($el); // eslint-disable-line no-new + } + }); +} + +export function initLabelIndex() { + Vue.use(Translate); + + initLabels(); + initDeleteLabelModal(); + + const onRequestFinished = ({ labelUrl, successful }) => { + const button = document.querySelector( + `.js-promote-project-label-button[data-url="${labelUrl}"]`, + ); + + if (!successful) { + button.removeAttribute('disabled'); + } + }; + + const onRequestStarted = (labelUrl) => { + const button = document.querySelector( + `.js-promote-project-label-button[data-url="${labelUrl}"]`, + ); + button.setAttribute('disabled', ''); + eventHub.$once('promoteLabelModal.requestFinished', onRequestFinished); + }; + + const promoteLabelButtons = document.querySelectorAll('.js-promote-project-label-button'); + + return new Vue({ + el: '#js-promote-label-modal', + data() { + return { + modalProps: { + labelTitle: '', + labelColor: '', + labelTextColor: '', + url: '', + groupName: '', + }, + }; + }, + mounted() { + eventHub.$on('promoteLabelModal.props', this.setModalProps); + eventHub.$emit('promoteLabelModal.mounted'); + + promoteLabelButtons.forEach((button) => { + button.removeAttribute('disabled'); + button.addEventListener('click', () => { + this.$root.$emit(BV_SHOW_MODAL, 'promote-label-modal'); + eventHub.$once('promoteLabelModal.requestStarted', onRequestStarted); + + this.setModalProps({ + labelTitle: button.dataset.labelTitle, + labelColor: button.dataset.labelColor, + labelTextColor: button.dataset.labelTextColor, + url: button.dataset.url, + groupName: button.dataset.groupName, + }); + }); + }); + }, + beforeDestroy() { + eventHub.$off('promoteLabelModal.props', this.setModalProps); + }, + methods: { + setModalProps(modalProps) { + this.modalProps = modalProps; + }, + }, + render(createElement) { + return createElement(PromoteLabelModal, { + props: this.modalProps, + }); + }, + }); +} + +export function initAdminLabels() { + const labelsContainer = document.querySelector('.js-admin-labels-container'); + const pagination = labelsContainer?.querySelector('.gl-pagination'); + const emptyState = document.querySelector('.js-admin-labels-empty-state'); + + function removeLabelSuccessCallback() { + this.closest('li').classList.add('gl-display-none!'); + + const labelsCount = document.querySelectorAll( + 'ul.manage-labels-list li:not(.gl-display-none\\!)', + ).length; + + // display the empty state if there are no more labels + if (labelsCount < 1 && !pagination && emptyState) { + emptyState.classList.remove('gl-display-none'); + labelsContainer.classList.add('gl-display-none'); + } + } + + document.querySelectorAll('.js-remove-label').forEach((row) => { + row.addEventListener('ajax:success', removeLabelSuccessCallback); + }); +} diff --git a/app/assets/javascripts/label_manager.js b/app/assets/javascripts/labels/label_manager.js index e0068edbb9b..1927ac6e1ec 100644 --- a/app/assets/javascripts/label_manager.js +++ b/app/assets/javascripts/labels/label_manager.js @@ -3,9 +3,9 @@ import $ from 'jquery'; import Sortable from 'sortablejs'; import { dispose } from '~/tooltips'; -import createFlash from './flash'; -import axios from './lib/utils/axios_utils'; -import { __ } from './locale'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; export default class LabelManager { constructor({ togglePriorityButton, prioritizedLabels, otherLabels } = {}) { diff --git a/app/assets/javascripts/labels.js b/app/assets/javascripts/labels/labels.js index cd8cf0d354c..cd8cf0d354c 100644 --- a/app/assets/javascripts/labels.js +++ b/app/assets/javascripts/labels/labels.js diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels/labels_select.js index 68019a35dbb..9d8ee165df2 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels/labels_select.js @@ -4,12 +4,12 @@ import $ from 'jquery'; import { difference, isEqual, escape, sortBy, template, union } from 'lodash'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import IssuableBulkUpdateActions from '~/issuable_bulk_update_sidebar/issuable_bulk_update_actions'; +import IssuableBulkUpdateActions from '~/issuable/bulk_update_sidebar/issuable_bulk_update_actions'; import { isScopedLabel } from '~/lib/utils/common_utils'; -import CreateLabelDropdown from './create_label'; -import createFlash from './flash'; -import axios from './lib/utils/axios_utils'; -import { sprintf, __ } from './locale'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { sprintf, __ } from '~/locale'; +import CreateLabelDropdown from './create_label_dropdown'; export default class LabelsSelect { constructor(els, options = {}) { @@ -101,7 +101,7 @@ export default class LabelsSelect { if (IS_EE) { /** * For Scoped labels, the last label selected with the - * same key will be applied to the current issueable. + * same key will be applied to the current issuable. * * If these are the labels - priority::1, priority::2; and if * we apply them in the same order, only priority::2 will stick diff --git a/app/assets/javascripts/project_label_subscription.js b/app/assets/javascripts/labels/project_label_subscription.js index f7804c2faa4..b2612e9ede0 100644 --- a/app/assets/javascripts/project_label_subscription.js +++ b/app/assets/javascripts/labels/project_label_subscription.js @@ -1,8 +1,8 @@ import $ from 'jquery'; import { fixTitle } from '~/tooltips'; -import createFlash from './flash'; -import axios from './lib/utils/axios_utils'; -import { __ } from './locale'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; const tooltipTitles = { group: { diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js index 47ede8cb1bb..47568f0ecff 100644 --- a/app/assets/javascripts/lib/dompurify.js +++ b/app/assets/javascripts/lib/dompurify.js @@ -3,7 +3,7 @@ import { getNormalizedURL, getBaseURL, relativePathToAbsolute } from '~/lib/util const defaultConfig = { // Safely allow SVG <use> tags - ADD_TAGS: ['use', 'gl-emoji'], + ADD_TAGS: ['use', 'gl-emoji', 'copy-code'], // Prevent possible XSS attacks with data-* attributes used by @rails/ujs // See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1421 FORBID_ATTR: ['data-remote', 'data-url', 'data-type', 'data-method'], diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index a82dad7e2c9..7235b38848c 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -735,3 +735,14 @@ export const isFeatureFlagEnabled = (flag) => window.gon.features?.[flag]; export const convertArrayToCamelCase = (array) => array.map((i) => convertToCamelCase(i)); export const isLoggedIn = () => Boolean(window.gon?.current_user_id); + +/** + * This method takes in array of objects with snake_case + * property names and returns a new array of objects with + * camelCase property names + * + * @param {Array[Object]} array - Array to be converted + * @returns {Array[Object]} Converted array + */ +export const convertArrayOfObjectsToCamelCase = (array) => + array.map((o) => convertObjectPropsToCamelCase(o)); diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index 36c6545164e..a108b02bcbf 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -17,6 +17,7 @@ export const BV_HIDE_MODAL = 'bv::hide::modal'; export const BV_HIDE_TOOLTIP = 'bv::hide::tooltip'; export const BV_DROPDOWN_SHOW = 'bv::dropdown::show'; export const BV_DROPDOWN_HIDE = 'bv::dropdown::hide'; +export const BV_COLLAPSE_STATE = 'bv::collapse::state'; export const DEFAULT_TH_CLASSES = 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!'; diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js index f7687a929de..b52a736f153 100644 --- a/app/assets/javascripts/lib/utils/dom_utils.js +++ b/app/assets/javascripts/lib/utils/dom_utils.js @@ -89,3 +89,17 @@ export const getParents = (element) => { return parents; }; + +/** + * This method takes a HTML element and an object of attributes + * to save repeated calls to `setAttribute` when multiple + * attributes need to be set. + * + * @param {HTMLElement} el + * @param {Object} attributes + */ +export const setAttributes = (el, attributes) => { + Object.keys(attributes).forEach((key) => { + el.setAttribute(key, attributes[key]); + }); +}; diff --git a/app/assets/javascripts/lib/utils/intersection_observer.js b/app/assets/javascripts/lib/utils/intersection_observer.js new file mode 100644 index 00000000000..0959df9a186 --- /dev/null +++ b/app/assets/javascripts/lib/utils/intersection_observer.js @@ -0,0 +1,28 @@ +import { memoize } from 'lodash'; + +import { uuids } from './uuids'; + +export const create = memoize((options = {}) => { + const id = uuids()[0]; + + return { + id, + observer: new IntersectionObserver((entries) => { + entries.forEach((entry) => { + entry.target.dispatchEvent( + new CustomEvent(`IntersectionUpdate`, { detail: { entry, observer: id } }), + ); + + if (entry.isIntersecting) { + entry.target.dispatchEvent( + new CustomEvent(`IntersectionAppear`, { detail: { observer: id } }), + ); + } else { + entry.target.dispatchEvent( + new CustomEvent(`IntersectionDisappear`, { detail: { observer: id } }), + ); + } + }); + }, options), + }; +}); diff --git a/app/assets/javascripts/lib/utils/navigation_utility.js b/app/assets/javascripts/lib/utils/navigation_utility.js index 1579b225e44..029e9f5fd9f 100644 --- a/app/assets/javascripts/lib/utils/navigation_utility.js +++ b/app/assets/javascripts/lib/utils/navigation_utility.js @@ -13,3 +13,42 @@ export default function findAndFollowLink(selector) { visitUrl(link); } } + +export function prefetchDocument(url) { + const newPrefetchLink = document.createElement('link'); + newPrefetchLink.rel = 'prefetch'; + newPrefetchLink.href = url; + newPrefetchLink.setAttribute('as', 'document'); + document.head.appendChild(newPrefetchLink); +} + +export function initPrefetchLinks(selector) { + document.querySelectorAll(selector).forEach((el) => { + let mouseOverTimer; + + const mouseOutHandler = () => { + if (mouseOverTimer) { + clearTimeout(mouseOverTimer); + mouseOverTimer = undefined; + } + }; + + const mouseOverHandler = () => { + el.addEventListener('mouseout', mouseOutHandler, { once: true, passive: true }); + + mouseOverTimer = setTimeout(() => { + if (el.href) prefetchDocument(el.href); + + // Only execute once + el.removeEventListener('mouseover', mouseOverHandler, true); + + mouseOverTimer = undefined; + }, 100); + }; + + el.addEventListener('mouseover', mouseOverHandler, { + capture: true, + passive: true, + }); + }); +} diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index e53a39cde06..12462a2575e 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -1,6 +1,6 @@ export const DASH_SCOPE = '-'; -const PATH_SEPARATOR = '/'; +export const PATH_SEPARATOR = '/'; const PATH_SEPARATOR_LEADING_REGEX = new RegExp(`^${PATH_SEPARATOR}+`); const PATH_SEPARATOR_ENDING_REGEX = new RegExp(`${PATH_SEPARATOR}+$`); const SHA_REGEX = /[\da-f]{40}/gi; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index e422d9b1a32..e221a54d9c6 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -14,9 +14,9 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { initRails } from '~/lib/utils/rails_ujs'; import * as popovers from '~/popovers'; import * as tooltips from '~/tooltips'; -import { initHeaderSearchApp } from '~/header_search'; +import { initPrefetchLinks } from '~/lib/utils/navigation_utility'; import initAlertHandler from './alert_handler'; -import { removeFlashClickListener } from './flash'; +import { addDismissFlashClickListener } from './flash'; import initTodoToggle from './header'; import initLayoutNav from './layout_nav'; import { logHelloDeferred } from './lib/logger/hello_deferred'; @@ -36,6 +36,7 @@ import GlFieldErrors from './gl_field_errors'; import initUserPopovers from './user_popovers'; import initBroadcastNotifications from './broadcast_notification'; import { initTopNav } from './nav'; +import { initCopyCodeButton } from './behaviors/copy_code'; import 'ee_else_ce/main_ee'; import 'jh_else_ce/main_jh'; @@ -90,6 +91,7 @@ function deferredInitialisation() { initTopNav(); initBreadcrumbs(); initTodoToggle(); + initPrefetchLinks('.js-prefetch-document'); initLogoAnimation(); initServicePingConsent(); initUserPopovers(); @@ -97,25 +99,31 @@ function deferredInitialisation() { initPersistentUserCallouts(); initDefaultTrackers(); initFeatureHighlight(); - - if (gon.features?.newHeaderSearch) { - initHeaderSearchApp(); - } else { - const search = document.querySelector('#search'); - if (search) { - search.addEventListener( - 'focus', - () => { + initCopyCodeButton(); + + const search = document.querySelector('#search'); + if (search) { + search.addEventListener( + 'focus', + () => { + if (gon.features?.newHeaderSearch) { + import(/* webpackChunkName: 'globalSearch' */ '~/header_search') + .then(async ({ initHeaderSearchApp }) => { + await initHeaderSearchApp(); + document.querySelector('#search').focus(); + }) + .catch(() => {}); + } else { import(/* webpackChunkName: 'globalSearch' */ './search_autocomplete') .then(({ default: initSearchAutocomplete }) => { const searchDropdown = initSearchAutocomplete(); searchDropdown.onSearchInputFocus(); }) .catch(() => {}); - }, - { once: true }, - ); - } + } + }, + { once: true }, + ); } addSelectOnFocusBehaviour('.js-select-on-focus'); @@ -259,7 +267,7 @@ if (flashContainer && flashContainer.children.length) { flashContainer .querySelectorAll('.flash-alert, .flash-notice, .flash-success') .forEach((flashEl) => { - removeFlashClickListener(flashEl); + addDismissFlashClickListener(flashEl); }); } diff --git a/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue index 35966be7363..d092283338c 100644 --- a/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue @@ -53,6 +53,7 @@ export default { :title="s__('Member|Deny access')" :is-access-request="true" icon="close" + button-category="primary" /> </div> </action-button-group> diff --git a/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue index 91062c222f4..ab9abfd38c6 100644 --- a/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue @@ -41,6 +41,8 @@ export default { <remove-member-button :member-id="member.id" :message="message" + icon="remove" + button-category="primary" :title="s__('Member|Revoke invite')" is-invite /> diff --git a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue index 69137ce615b..01606d07554 100644 --- a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue @@ -30,7 +30,17 @@ export default { icon: { type: String, required: false, - default: 'remove', + default: undefined, + }, + buttonText: { + type: String, + required: false, + default: '', + }, + buttonCategory: { + type: String, + required: false, + default: 'secondary', }, isAccessRequest: { type: Boolean, @@ -79,10 +89,12 @@ export default { <gl-button v-gl-tooltip variant="danger" + :category="buttonCategory" :title="title" :aria-label="title" :icon="icon" data-qa-selector="delete_member_button" @click="showRemoveMemberModal(modalData)" - /> + ><template v-if="buttonText">{{ buttonText }}</template></gl-button + > </template> diff --git a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue index 44d658c90a0..594da7f68cc 100644 --- a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue @@ -1,5 +1,5 @@ <script> -import { s__, sprintf } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; import ActionButtonGroup from './action_button_group.vue'; import LeaveButton from './leave_button.vue'; @@ -23,6 +23,10 @@ export default { type: Boolean, required: true, }, + isInvitedUser: { + type: Boolean, + required: true, + }, permissions: { type: Object, required: true, @@ -56,6 +60,15 @@ export default { obstacles: parseUserDeletionObstacles(this.member.user), }; }, + removeMemberButtonText() { + return this.isInvitedUser ? null : __('Remove user'); + }, + removeMemberButtonIcon() { + return this.isInvitedUser ? 'remove' : ''; + }, + removeMemberButtonCategory() { + return this.isInvitedUser ? 'primary' : 'secondary'; + }, }, }; </script> @@ -70,6 +83,9 @@ export default { :member-type="member.type" :user-deletion-obstacles="userDeletionObstaclesUserData" :message="message" + :icon="removeMemberButtonIcon" + :button-text="removeMemberButtonText" + :button-category="removeMemberButtonCategory" :title="s__('Member|Remove member')" /> </div> diff --git a/app/assets/javascripts/members/components/table/member_action_buttons.vue b/app/assets/javascripts/members/components/table/member_action_buttons.vue index 6f15f079d2d..971b1a8435e 100644 --- a/app/assets/javascripts/members/components/table/member_action_buttons.vue +++ b/app/assets/javascripts/members/components/table/member_action_buttons.vue @@ -30,6 +30,10 @@ export default { type: Boolean, required: true, }, + isInvitedUser: { + type: Boolean, + required: true, + }, }, computed: { actionButtonComponent() { @@ -53,5 +57,6 @@ export default { :member="member" :permissions="permissions" :is-current-user="isCurrentUser" + :is-invited-user="isInvitedUser" /> </template> diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue index 202f3aa89e1..de733ae75df 100644 --- a/app/assets/javascripts/members/components/table/members_table.vue +++ b/app/assets/javascripts/members/components/table/members_table.vue @@ -8,6 +8,7 @@ import initUserPopovers from '~/user_popovers'; import { FIELDS, ACTIVE_TAB_QUERY_PARAM_NAME, + TAB_QUERY_PARAM_VALUES, MEMBER_STATE_AWAITING, USER_STATE_BLOCKED_PENDING_APPROVAL, BADGE_LABELS_PENDING_OWNER_APPROVAL, @@ -82,6 +83,9 @@ export default { return paramName && currentPage && perPage && totalItems; }, + isInvitedUser() { + return this.tabQueryParamValue === TAB_QUERY_PARAM_VALUES.invite; + }, }, mounted() { initUserPopovers(this.$el.querySelectorAll('.js-user-link')); @@ -275,6 +279,7 @@ export default { <member-action-buttons :member-type="memberType" :is-current-user="isCurrentUser" + :is-invited-user="isInvitedUser" :permissions="permissions" :member="member" /> diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js index cf02c6fbd6b..8c96f8a017e 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js +++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import initIssuableSidebar from '../init_issuable_sidebar'; +import { initIssuableSidebar } from '~/issuable'; import MergeConflictsResolverApp from './merge_conflict_resolver_app.vue'; import { createStore } from './store'; diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js deleted file mode 100644 index b4e53c1fab6..00000000000 --- a/app/assets/javascripts/milestone.js +++ /dev/null @@ -1,49 +0,0 @@ -import $ from 'jquery'; -import createFlash from './flash'; -import axios from './lib/utils/axios_utils'; -import { __ } from './locale'; - -export default class Milestone { - constructor() { - this.bindTabsSwitching(); - this.loadInitialTab(); - } - - bindTabsSwitching() { - return $('a[data-toggle="tab"]').on('show.bs.tab', (e) => { - const $target = $(e.target); - - window.location.hash = $target.attr('href'); - this.loadTab($target); - }); - } - - loadInitialTab() { - const $target = $(`.js-milestone-tabs a:not(.active)[href="${window.location.hash}"]`); - - if ($target.length) { - $target.tab('show'); - } else { - this.loadTab($('.js-milestone-tabs a.active')); - } - } - // eslint-disable-next-line class-methods-use-this - loadTab($target) { - const endpoint = $target.data('endpoint'); - const tabElId = $target.attr('href'); - - if (endpoint && !$target.hasClass('is-loaded')) { - axios - .get(endpoint) - .then(({ data }) => { - $(tabElId).html(data.html); - $target.addClass('is-loaded'); - }) - .catch(() => - createFlash({ - message: __('Error loading milestone tab'), - }), - ); - } - } -} diff --git a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue index 34f9fe778ea..34f9fe778ea 100644 --- a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue +++ b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue diff --git a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue b/app/assets/javascripts/milestones/components/promote_milestone_modal.vue index b41611001ab..b41611001ab 100644 --- a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue +++ b/app/assets/javascripts/milestones/components/promote_milestone_modal.vue diff --git a/app/assets/javascripts/pages/milestones/shared/event_hub.js b/app/assets/javascripts/milestones/event_hub.js index e31806ad199..e31806ad199 100644 --- a/app/assets/javascripts/pages/milestones/shared/event_hub.js +++ b/app/assets/javascripts/milestones/event_hub.js diff --git a/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js b/app/assets/javascripts/milestones/index.js index 3aeff2db2e0..2ca5f104b4f 100644 --- a/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js +++ b/app/assets/javascripts/milestones/index.js @@ -1,10 +1,58 @@ +import $ from 'jquery'; import Vue from 'vue'; +import initDatePicker from '~/behaviors/date_picker'; +import GLForm from '~/gl_form'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; +import Milestone from '~/milestones/milestone'; +import Sidebar from '~/right_sidebar'; +import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar'; import Translate from '~/vue_shared/translate'; +import ZenMode from '~/zen_mode'; import DeleteMilestoneModal from './components/delete_milestone_modal.vue'; +import PromoteMilestoneModal from './components/promote_milestone_modal.vue'; import eventHub from './event_hub'; -export default () => { +export function initForm(initGFM = true) { + new ZenMode(); // eslint-disable-line no-new + initDatePicker(); + + // eslint-disable-next-line no-new + new GLForm($('.milestone-form'), { + emojis: true, + members: initGFM, + issues: initGFM, + mergeRequests: initGFM, + epics: initGFM, + milestones: initGFM, + labels: initGFM, + snippets: initGFM, + vulnerabilities: initGFM, + }); +} + +export function initShow() { + new Milestone(); // eslint-disable-line no-new + new Sidebar(); // eslint-disable-line no-new + new MountMilestoneSidebar(); // eslint-disable-line no-new +} + +export function initPromoteMilestoneModal() { + Vue.use(Translate); + + const promoteMilestoneModal = document.getElementById('promote-milestone-modal'); + if (!promoteMilestoneModal) { + return null; + } + + return new Vue({ + el: promoteMilestoneModal, + render(createElement) { + return createElement(PromoteMilestoneModal); + }, + }); +} + +export function initDeleteMilestoneModal() { Vue.use(Translate); const onRequestFinished = ({ milestoneUrl, successful }) => { @@ -72,4 +120,4 @@ export default () => { }); }, }); -}; +} diff --git a/app/assets/javascripts/milestones/milestone.js b/app/assets/javascripts/milestones/milestone.js new file mode 100644 index 00000000000..05102f73f92 --- /dev/null +++ b/app/assets/javascripts/milestones/milestone.js @@ -0,0 +1,49 @@ +import createFlash from '~/flash'; +import { sanitize } from '~/lib/dompurify'; +import axios from '~/lib/utils/axios_utils'; +import { historyPushState } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +import { GlTabsBehavior, TAB_SHOWN_EVENT } from '~/tabs'; + +export default class Milestone { + constructor() { + this.tabsEl = document.querySelector('.js-milestone-tabs'); + this.glTabs = new GlTabsBehavior(this.tabsEl); + this.loadedTabs = new WeakSet(); + + this.bindTabsSwitching(); + this.loadInitialTab(); + } + + bindTabsSwitching() { + this.tabsEl.addEventListener(TAB_SHOWN_EVENT, (event) => { + const tab = event.target; + const { activeTabPanel } = event.detail; + historyPushState(tab.getAttribute('href')); + this.loadTab(tab, activeTabPanel); + }); + } + + loadInitialTab() { + const tab = this.tabsEl.querySelector(`a[href="${window.location.hash}"]`); + this.glTabs.activateTab(tab || this.glTabs.activeTab); + } + loadTab(tab, tabPanel) { + const { endpoint } = tab.dataset; + + if (endpoint && !this.loadedTabs.has(tab)) { + axios + .get(endpoint) + .then(({ data }) => { + // eslint-disable-next-line no-param-reassign + tabPanel.innerHTML = sanitize(data.html); + this.loadedTabs.add(tab); + }) + .catch(() => + createFlash({ + message: __('Error loading milestone tab'), + }), + ); + } + } +} diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestones/milestone_select.js index aa8a40b6a87..c95ec3dd10b 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestones/milestone_select.js @@ -6,9 +6,9 @@ import { template, escape } from 'lodash'; import Api from '~/api'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import { __, sprintf } from '~/locale'; -import { sortMilestonesByDueDate } from '~/milestones/milestone_utils'; -import axios from './lib/utils/axios_utils'; -import { timeFor, parsePikadayDate, dateInWords } from './lib/utils/datetime_utility'; +import { sortMilestonesByDueDate } from '~/milestones/utils'; +import axios from '~/lib/utils/axios_utils'; +import { timeFor, parsePikadayDate, dateInWords } from '~/lib/utils/datetime_utility'; export default class MilestoneSelect { constructor(currentProject, els, options = {}) { diff --git a/app/assets/javascripts/milestones/milestone_utils.js b/app/assets/javascripts/milestones/utils.js index 3ae5e676138..3ae5e676138 100644 --- a/app/assets/javascripts/milestones/milestone_utils.js +++ b/app/assets/javascripts/milestones/utils.js diff --git a/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql b/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql index 302383512d3..a61d601cd34 100644 --- a/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql +++ b/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql @@ -7,6 +7,7 @@ query getDashboardValidationWarnings( id environments(name: $environmentName) { nodes { + id name metricsDashboard(path: $dashboardPath) { path diff --git a/app/assets/javascripts/mr_popover/components/mr_popover.vue b/app/assets/javascripts/mr_popover/components/mr_popover.vue index 791fdf7660f..d99a3adb358 100644 --- a/app/assets/javascripts/mr_popover/components/mr_popover.vue +++ b/app/assets/javascripts/mr_popover/components/mr_popover.vue @@ -65,6 +65,9 @@ export default { return humanMRStates.open; } }, + title() { + return this.mergeRequest?.title || this.mergeRequestTitle; + }, showDetails() { return Object.keys(this.mergeRequest).length > 0; }, @@ -89,7 +92,7 @@ export default { <template> <gl-popover :target="target" boundary="viewport" placement="top" show> <div class="mr-popover"> - <div v-if="$apollo.loading"> + <div v-if="$apollo.queries.mergeRequest.loading"> <gl-skeleton-loading :lines="1" class="animation-container-small mt-1" /> </div> <div v-else-if="showDetails" class="d-flex align-items-center justify-content-between"> @@ -97,13 +100,13 @@ export default { <div :class="`issuable-status-box status-box ${statusBoxClass}`"> {{ stateHumanName }} </div> - <span class="text-secondary">Opened <time v-text="formattedTime"></time></span> + <span class="gl-text-secondary">Opened <time v-text="formattedTime"></time></span> </div> <ci-icon v-if="detailedStatus" :status="detailedStatus" /> </div> - <h5 class="my-2">{{ mergeRequestTitle }}</h5> + <h5 v-if="!$apollo.queries.mergeRequest.loading" class="my-2">{{ title }}</h5> <!-- eslint-disable @gitlab/vue-require-i18n-strings --> - <div class="text-secondary"> + <div class="gl-text-secondary"> {{ `${projectPath}!${mergeRequestIID}` }} </div> <!-- eslint-enable @gitlab/vue-require-i18n-strings --> diff --git a/app/assets/javascripts/mr_popover/queries/merge_request.query.graphql b/app/assets/javascripts/mr_popover/queries/merge_request.query.graphql index 37d4bc88a69..b3e5d89d495 100644 --- a/app/assets/javascripts/mr_popover/queries/merge_request.query.graphql +++ b/app/assets/javascripts/mr_popover/queries/merge_request.query.graphql @@ -1,10 +1,15 @@ query mergeRequest($projectPath: ID!, $mergeRequestIID: String!) { project(fullPath: $projectPath) { + id mergeRequest(iid: $mergeRequestIID) { + id + title createdAt state headPipeline { + id detailedStatus { + id icon group } diff --git a/app/assets/javascripts/network/branch_graph.js b/app/assets/javascripts/network/branch_graph.js index 54fe9d19002..71894b4ff3e 100644 --- a/app/assets/javascripts/network/branch_graph.js +++ b/app/assets/javascripts/network/branch_graph.js @@ -98,6 +98,7 @@ export default class BranchGraph { let len = 0; let cuday = 0; let cumonth = ''; + let cuyear = ''; const { r } = this; r.rect(0, 0, 40, this.barHeight).attr({ fill: '#222', @@ -108,24 +109,21 @@ export default class BranchGraph { const ref = this.days; for (mm = 0, len = ref.length; mm < len; mm += 1) { const day = ref[mm]; - if (cuday !== day[0] || cumonth !== day[1]) { + if (cuday !== day[0] || cumonth !== day[1] || cuyear !== day[2]) { // Dates r.text(55, this.offsetY + this.unitTime * mm, day[0]).attr({ font: '12px Monaco, monospace', fill: '#BBB', }); - [cuday] = day; } - if (cumonth !== day[1]) { + if (cumonth !== day[1] || cuyear !== day[2]) { // Months r.text(20, this.offsetY + this.unitTime * mm, day[1]).attr({ font: '12px Monaco, monospace', fill: '#EEE', }); - - // eslint-disable-next-line prefer-destructuring - cumonth = day[1]; } + [cuday, cumonth, cuyear] = day; } this.renderPartialGraph(); return this.bindEvents(); diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 4e31fdcd4f0..996c008b881 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -11,7 +11,6 @@ import httpStatusCodes from '~/lib/utils/http_status'; import { capitalizeFirstCharacter, convertToCamelCase, - splitCamelCase, slugifyWithUnderscore, } from '~/lib/utils/text_utility'; import { sprintf } from '~/locale'; @@ -77,7 +76,15 @@ export default { ]), ...mapState(['isToggleStateButtonLoading']), noteableDisplayName() { - return splitCamelCase(this.noteableType).toLowerCase(); + const displayNameMap = { + [constants.ISSUE_NOTEABLE_TYPE]: this.$options.i18n.issue, + [constants.EPIC_NOTEABLE_TYPE]: this.$options.i18n.epic, + [constants.MERGE_REQUEST_NOTEABLE_TYPE]: this.$options.i18n.mergeRequest, + }; + + const noteableTypeKey = + constants.NOTEABLE_TYPE_MAPPING[this.noteableType] || constants.ISSUE_NOTEABLE_TYPE; + return displayNameMap[noteableTypeKey]; }, isLoggedIn() { return this.getUserData.id; @@ -103,15 +110,13 @@ export default { const openOrClose = this.isOpen ? 'close' : 'reopen'; if (this.note.length) { - return sprintf(this.$options.i18n.actionButtonWithNote, { + return sprintf(this.$options.i18n.actionButton.withNote[openOrClose], { actionText: this.commentButtonTitle, - openOrClose, noteable: this.noteableDisplayName, }); } - return sprintf(this.$options.i18n.actionButton, { - openOrClose: capitalizeFirstCharacter(openOrClose), + return sprintf(this.$options.i18n.actionButton.withoutNote[openOrClose], { noteable: this.noteableDisplayName, }); }, @@ -151,13 +156,8 @@ export default { draftEndpoint() { return this.getNotesData.draftsPath; }, - issuableTypeTitle() { - return this.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE - ? this.$options.i18n.mergeRequest - : this.$options.i18n.issue; - }, isIssue() { - return this.noteableDisplayName === constants.ISSUE_NOTEABLE_TYPE; + return constants.NOTEABLE_TYPE_MAPPING[this.noteableType] === constants.ISSUE_NOTEABLE_TYPE; }, trackingLabel() { return slugifyWithUnderscore(`${this.commentButtonTitle} button`); @@ -329,7 +329,7 @@ export default { <template> <div> <note-signed-out-widget v-if="!isLoggedIn" /> - <discussion-locked-widget v-else-if="!canCreateNote" :issuable-type="issuableTypeTitle" /> + <discussion-locked-widget v-else-if="!canCreateNote" :issuable-type="noteableDisplayName" /> <ul v-else-if="canCreateNote" class="notes notes-form timeline"> <timeline-entry-item class="note-form"> <gl-alert diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index b04aa74d46e..b2d5910fd3f 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -1,5 +1,8 @@ <script> -import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; +import { + GlDeprecatedSkeletonLoading as GlSkeletonLoading, + GlSafeHtmlDirective as SafeHtml, +} from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import DiffFileHeader from '~/diffs/components/diff_file_header.vue'; import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue'; @@ -17,6 +20,9 @@ export default { DiffViewer, ImageDiffOverlay, }, + directives: { + SafeHtml, + }, props: { discussion: { type: Object, @@ -92,11 +98,7 @@ export default { > <td :class="line.type" class="diff-line-num old_line">{{ line.old_line }}</td> <td :class="line.type" class="diff-line-num new_line">{{ line.new_line }}</td> - <td - :class="line.type" - class="line_content" - v-html="trimChar(line.rich_text) /* eslint-disable-line vue/no-v-html */" - ></td> + <td v-safe-html="trimChar(line.rich_text)" :class="line.type" class="line_content"></td> </tr> </template> <tr v-if="!hasTruncatedDiffLines" class="line_holder line-holder-placeholder"> diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue index 88f053aed67..102afaf308f 100644 --- a/app/assets/javascripts/notes/components/discussion_filter.vue +++ b/app/assets/javascripts/notes/components/discussion_filter.vue @@ -39,7 +39,7 @@ export default { }; }, computed: { - ...mapGetters(['getNotesDataByProp', 'timelineEnabled']), + ...mapGetters(['getNotesDataByProp', 'timelineEnabled', 'isLoading']), currentFilter() { if (!this.currentValue) return this.filters[0]; return this.filters.find((filter) => filter.value === this.currentValue); @@ -119,6 +119,7 @@ export default { class="gl-mr-3 full-width-mobile discussion-filter-container js-discussion-filter-container" data-qa-selector="discussion_filter_dropdown" :text="currentFilter.title" + :disabled="isLoading" > <div v-for="filter in filters" :key="filter.value" class="dropdown-item-wrapper"> <gl-dropdown-item diff --git a/app/assets/javascripts/notes/components/discussion_locked_widget.vue b/app/assets/javascripts/notes/components/discussion_locked_widget.vue index 2f215e36d5b..8ac3f6bea68 100644 --- a/app/assets/javascripts/notes/components/discussion_locked_widget.vue +++ b/app/assets/javascripts/notes/components/discussion_locked_widget.vue @@ -1,7 +1,6 @@ <script> import { GlLink, GlIcon } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; -import Issuable from '~/vue_shared/mixins/issuable'; import issuableStateMixin from '../mixins/issuable_state'; export default { @@ -9,8 +8,17 @@ export default { GlIcon, GlLink, }, - mixins: [Issuable, issuableStateMixin], + mixins: [issuableStateMixin], + props: { + issuableType: { + required: true, + type: String, + }, + }, computed: { + issuableDisplayName() { + return this.issuableType.replace(/_/g, ' '); + }, projectArchivedWarning() { return __('This project is archived and cannot be commented on.'); }, diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue index d1df4eb848b..6fcfa66ea49 100644 --- a/app/assets/javascripts/notes/components/discussion_notes.vue +++ b/app/assets/javascripts/notes/components/discussion_notes.vue @@ -1,6 +1,5 @@ <script> import { mapGetters, mapActions } from 'vuex'; -import { GlIntersectionObserver } from '@gitlab/ui'; import { __ } from '~/locale'; import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue'; @@ -17,9 +16,7 @@ export default { ToggleRepliesWidget, NoteEditedText, DiscussionNotesRepliesWrapper, - GlIntersectionObserver, }, - inject: ['discussionObserverHandler'], props: { discussion: { type: Object, @@ -57,11 +54,7 @@ export default { }, }, computed: { - ...mapGetters([ - 'userCanReply', - 'previousUnresolvedDiscussionId', - 'firstUnresolvedDiscussionId', - ]), + ...mapGetters(['userCanReply']), hasReplies() { return Boolean(this.replies.length); }, @@ -84,20 +77,9 @@ export default { url: this.discussion.discussion_path, }; }, - isFirstUnresolved() { - return this.firstUnresolvedDiscussionId === this.discussion.id; - }, - }, - observerOptions: { - threshold: 0, - rootMargin: '0px 0px -50% 0px', }, methods: { - ...mapActions([ - 'toggleDiscussion', - 'setSelectedCommentPositionHover', - 'setCurrentDiscussionId', - ]), + ...mapActions(['toggleDiscussion', 'setSelectedCommentPositionHover']), componentName(note) { if (note.isPlaceholderNote) { if (note.placeholderType === SYSTEM_NOTE) { @@ -128,18 +110,6 @@ export default { this.setSelectedCommentPositionHover(); } }, - observerTriggered(entry) { - this.discussionObserverHandler({ - entry, - isFirstUnresolved: this.isFirstUnresolved, - currentDiscussion: { ...this.discussion }, - isDiffsPage: !this.isOverviewTab, - functions: { - setCurrentDiscussionId: this.setCurrentDiscussionId, - getPreviousUnresolvedDiscussionId: this.previousUnresolvedDiscussionId, - }, - }); - }, }, }; </script> @@ -152,35 +122,33 @@ export default { @mouseleave="handleMouseLeave(discussion)" > <template v-if="shouldGroupReplies"> - <gl-intersection-observer :options="$options.observerOptions" @update="observerTriggered"> - <component - :is="componentName(firstNote)" - :note="componentData(firstNote)" - :line="line || diffLine" - :discussion-file="discussion.diff_file" - :commit="commit" - :help-page-path="helpPagePath" - :show-reply-button="userCanReply" - :discussion-root="true" - :discussion-resolve-path="discussion.resolve_path" - :is-overview-tab="isOverviewTab" - @handleDeleteNote="$emit('deleteNote')" - @startReplying="$emit('startReplying')" - > - <template #discussion-resolved-text> - <note-edited-text - v-if="discussion.resolved" - :edited-at="discussion.resolved_at" - :edited-by="discussion.resolved_by" - :action-text="resolvedText" - class-name="discussion-headline-light js-discussion-headline discussion-resolved-text" - /> - </template> - <template #avatar-badge> - <slot name="avatar-badge"></slot> - </template> - </component> - </gl-intersection-observer> + <component + :is="componentName(firstNote)" + :note="componentData(firstNote)" + :line="line || diffLine" + :discussion-file="discussion.diff_file" + :commit="commit" + :help-page-path="helpPagePath" + :show-reply-button="userCanReply" + :discussion-root="true" + :discussion-resolve-path="discussion.resolve_path" + :is-overview-tab="isOverviewTab" + @handleDeleteNote="$emit('deleteNote')" + @startReplying="$emit('startReplying')" + > + <template #discussion-resolved-text> + <note-edited-text + v-if="discussion.resolved" + :edited-at="discussion.resolved_at" + :edited-by="discussion.resolved_by" + :action-text="resolvedText" + class-name="discussion-headline-light js-discussion-headline discussion-resolved-text" + /> + </template> + <template #avatar-badge> + <slot name="avatar-badge"></slot> + </template> + </component> <discussion-notes-replies-wrapper :is-diff-discussion="discussion.diff_discussion"> <toggle-replies-widget v-if="hasReplies" diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index c09582d6287..f465ad23a06 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -149,7 +149,7 @@ export default { }, }, safeHtmlConfig: { - ADD_TAGS: ['use', 'gl-emoji'], + ADD_TAGS: ['use', 'gl-emoji', 'copy-code'], }, }; </script> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 77f796fe8b0..8e32c3b3073 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -223,17 +223,20 @@ export default { }) .catch((err) => { this.removePlaceholderNotes(); - const msg = __( - 'Your comment could not be submitted! Please check your network connection and try again.', - ); - createFlash({ - message: msg, - parent: this.$el, - }); + this.handleSaveError(err); // The 'err' parameter is being used in JH, don't remove it this.$refs.noteForm.note = noteText; callback(err); }); }, + handleSaveError() { + const msg = __( + 'Your comment could not be submitted! Please check your network connection and try again.', + ); + createFlash({ + message: msg, + parent: this.$el, + }); + }, deleteNoteHandler(note) { this.$emit('noteDeleted', this.discussion, note); }, @@ -280,6 +283,7 @@ export default { v-if="showDraft(discussion.reply_id)" :key="`draft_${discussion.id}`" :draft="draftForDiscussion(discussion.reply_id)" + :line="line" /> <div v-else-if="canShowReplyActions && showReplies" diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index e35d8d94289..3250a4818c7 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -331,17 +331,20 @@ export default { this.isEditing = true; this.setSelectedCommentPositionHover(); this.$nextTick(() => { - const msg = __('Something went wrong while editing your comment. Please try again.'); - createFlash({ - message: msg, - parent: this.$el, - }); + this.handleUpdateError(response); // The 'response' parameter is being used in JH, don't remove it this.recoverNoteContent(noteText); callback(); }); } }); }, + handleUpdateError() { + const msg = __('Something went wrong while editing your comment. Please try again.'); + createFlash({ + message: msg, + parent: this.$el, + }); + }, formCancelHandler({ shouldConfirm, isDirty }) { if (shouldConfirm && isDirty) { // eslint-disable-next-line no-alert @@ -388,7 +391,7 @@ export default { <div v-if="showMultiLineComment" data-testid="multiline-comment" - class="gl-mb-3 gl-text-gray-500 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3" + class="gl-mb-5 gl-text-gray-500 gl-border-gray-100 gl-border-b-solid gl-border-b-1 gl-pb-4" > <gl-sprintf :message="__('Comment on lines %{startLine} to %{endLine}')"> <template #startLine> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 3ab3e7a20d4..c4924cd41f5 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -8,7 +8,6 @@ import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item import OrderedLayout from '~/vue_shared/components/ordered_layout.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import draftNote from '../../batch_comments/components/draft_note.vue'; -import { discussionIntersectionObserverHandlerFactory } from '../../diffs/utils/discussions'; import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility'; import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue'; @@ -39,9 +38,6 @@ export default { TimelineEntryItem, }, mixins: [glFeatureFlagsMixin()], - provide: { - discussionObserverHandler: discussionIntersectionObserverHandlerFactory(), - }, props: { noteableData: { type: Object, @@ -108,6 +104,10 @@ export default { }); } + if (this.sortDirDesc) { + return skeletonNotes.concat(this.discussions); + } + return this.discussions.concat(skeletonNotes); }, canReply() { diff --git a/app/assets/javascripts/notes/components/sidebar_subscription.vue b/app/assets/javascripts/notes/components/sidebar_subscription.vue index 047c04c8482..52dadc7b4c3 100644 --- a/app/assets/javascripts/notes/components/sidebar_subscription.vue +++ b/app/assets/javascripts/notes/components/sidebar_subscription.vue @@ -1,6 +1,6 @@ <script> import { mapActions } from 'vuex'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { fetchPolicies } from '~/lib/graphql'; import { confidentialityQueries } from '~/sidebar/constants'; import { defaultClient as gqlClient } from '~/sidebar/graphql'; diff --git a/app/assets/javascripts/notes/i18n.js b/app/assets/javascripts/notes/i18n.js index 1ffb94d11ad..951fa9733d4 100644 --- a/app/assets/javascripts/notes/i18n.js +++ b/app/assets/javascripts/notes/i18n.js @@ -9,15 +9,27 @@ export const COMMENT_FORM = { issue: __('issue'), startThread: __('Start thread'), mergeRequest: __('merge request'), + epic: __('epic'), bodyPlaceholder: __('Write a comment or drag your files here…'), confidential: s__('Notes|Make this comment confidential'), - confidentialVisibility: s__('Notes|Confidential comments are only visible to project members'), + confidentialVisibility: s__( + 'Notes|Confidential comments are only visible to members with the role of Reporter or higher', + ), discussionThatNeedsResolution: __( 'Discuss a specific suggestion or question that needs to be resolved.', ), discussion: __('Discuss a specific suggestion or question.'), actionButtonWithNote: __('%{actionText} & %{openOrClose} %{noteable}'), - actionButton: __('%{openOrClose} %{noteable}'), + actionButton: { + withNote: { + reopen: __('%{actionText} & reopen %{noteable}'), + close: __('%{actionText} & close %{noteable}'), + }, + withoutNote: { + reopen: __('Reopen %{noteable}'), + close: __('Close %{noteable}'), + }, + }, submitButton: { startThread: __('Start thread'), comment: __('Comment'), diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index c862a29ad9c..50b05ea9d69 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -601,7 +601,8 @@ export const setLoadingState = ({ commit }, data) => { commit(types.SET_NOTES_LOADING_STATE, data); }; -export const filterDiscussion = ({ dispatch }, { path, filter, persistFilter }) => { +export const filterDiscussion = ({ commit, dispatch }, { path, filter, persistFilter }) => { + commit(types.CLEAR_DISCUSSIONS); dispatch('setLoadingState', true); dispatch('fetchDiscussions', { path, filter, persistFilter }) .then(() => { diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index fcd2846ff0d..ebda08a3d62 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -1,6 +1,7 @@ export const ADD_NEW_NOTE = 'ADD_NEW_NOTE'; export const ADD_NEW_REPLY_TO_DISCUSSION = 'ADD_NEW_REPLY_TO_DISCUSSION'; export const ADD_OR_UPDATE_DISCUSSIONS = 'ADD_OR_UPDATE_DISCUSSIONS'; +export const CLEAR_DISCUSSIONS = 'CLEAR_DISCUSSIONS'; export const DELETE_NOTE = 'DELETE_NOTE'; export const REMOVE_PLACEHOLDER_NOTES = 'REMOVE_PLACEHOLDER_NOTES'; export const SET_NOTES_DATA = 'SET_NOTES_DATA'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 1a99750ddb3..ba19ecd0c04 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -129,6 +129,10 @@ export default { Object.assign(state, { userData: data }); }, + [types.CLEAR_DISCUSSIONS](state) { + state.discussions = []; + }, + [types.ADD_OR_UPDATE_DISCUSSIONS](state, discussionsData) { discussionsData.forEach((d) => { const discussion = { ...d }; diff --git a/app/assets/javascripts/packages/list/packages_list_app_bundle.js b/app/assets/javascripts/packages/list/packages_list_app_bundle.js deleted file mode 100644 index 2911cf70a33..00000000000 --- a/app/assets/javascripts/packages/list/packages_list_app_bundle.js +++ /dev/null @@ -1,23 +0,0 @@ -import Vue from 'vue'; -import Translate from '~/vue_shared/translate'; -import PackagesListApp from './components/packages_list_app.vue'; -import { createStore } from './stores'; - -Vue.use(Translate); - -export default () => { - const el = document.getElementById('js-vue-packages-list'); - const store = createStore(); - store.dispatch('setInitialState', el.dataset); - - return new Vue({ - el, - store, - components: { - PackagesListApp, - }, - render(createElement) { - return createElement('packages-list-app'); - }, - }); -}; diff --git a/app/assets/javascripts/packages/shared/constants.js b/app/assets/javascripts/packages/shared/constants.js deleted file mode 100644 index c284b8358b4..00000000000 --- a/app/assets/javascripts/packages/shared/constants.js +++ /dev/null @@ -1,49 +0,0 @@ -import { s__ } from '~/locale'; - -export const PackageType = { - CONAN: 'conan', - MAVEN: 'maven', - NPM: 'npm', - NUGET: 'nuget', - PYPI: 'pypi', - COMPOSER: 'composer', - RUBYGEMS: 'rubygems', - GENERIC: 'generic', - DEBIAN: 'debian', - HELM: 'helm', -}; - -// we want this separated from the main dictionary to avoid it being pulled in the search of package -export const TERRAFORM_PACKAGE_TYPE = 'terraform_module'; - -export const TrackingActions = { - DELETE_PACKAGE: 'delete_package', - REQUEST_DELETE_PACKAGE: 'request_delete_package', - CANCEL_DELETE_PACKAGE: 'cancel_delete_package', - PULL_PACKAGE: 'pull_package', - DELETE_PACKAGE_FILE: 'delete_package_file', - REQUEST_DELETE_PACKAGE_FILE: 'request_delete_package_file', - CANCEL_DELETE_PACKAGE_FILE: 'cancel_delete_package_file', -}; - -export const TrackingCategories = { - [PackageType.MAVEN]: 'MavenPackages', - [PackageType.NPM]: 'NpmPackages', - [PackageType.CONAN]: 'ConanPackages', -}; - -export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert'; -export const DELETE_PACKAGE_ERROR_MESSAGE = s__( - 'PackageRegistry|Something went wrong while deleting the package.', -); -export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__( - 'PackageRegistry|Something went wrong while deleting the package file.', -); -export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__( - 'PackageRegistry|Package file deleted successfully', -); - -export const PACKAGE_ERROR_STATUS = 'error'; -export const PACKAGE_DEFAULT_STATUS = 'default'; -export const PACKAGE_HIDDEN_STATUS = 'hidden'; -export const PACKAGE_PROCESSING_STATUS = 'processing'; diff --git a/app/assets/javascripts/packages/shared/utils.js b/app/assets/javascripts/packages/shared/utils.js deleted file mode 100644 index 7e86e5b2991..00000000000 --- a/app/assets/javascripts/packages/shared/utils.js +++ /dev/null @@ -1,43 +0,0 @@ -import { s__ } from '~/locale'; -import { PackageType, TrackingCategories } from './constants'; - -export const packageTypeToTrackCategory = (type) => - // eslint-disable-next-line @gitlab/require-i18n-strings - `UI::${TrackingCategories[type]}`; - -export const beautifyPath = (path) => (path ? path.split('/').join(' / ') : ''); - -export const getPackageTypeLabel = (packageType) => { - switch (packageType) { - case PackageType.CONAN: - return s__('PackageRegistry|Conan'); - case PackageType.MAVEN: - return s__('PackageRegistry|Maven'); - case PackageType.NPM: - return s__('PackageRegistry|npm'); - case PackageType.NUGET: - return s__('PackageRegistry|NuGet'); - case PackageType.PYPI: - return s__('PackageRegistry|PyPI'); - case PackageType.RUBYGEMS: - return s__('PackageRegistry|RubyGems'); - case PackageType.COMPOSER: - return s__('PackageRegistry|Composer'); - case PackageType.GENERIC: - return s__('PackageRegistry|Generic'); - case PackageType.DEBIAN: - return s__('PackageRegistry|Debian'); - case PackageType.HELM: - return s__('PackageRegistry|Helm'); - default: - return null; - } -}; - -export const getCommitLink = ({ project_path: projectPath, pipeline = {} }, isGroup = false) => { - if (isGroup) { - return `/${projectPath}/commit/${pipeline.sha}`; - } - - return `../commit/${pipeline.sha}`; -}; diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue index f857c96c9d1..7a8a1bbcf09 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue @@ -82,6 +82,7 @@ export default { ref="deleteModal" modal-id="delete-tag-modal" ok-variant="danger" + size="sm" :action-primary="{ text: __('Delete'), attributes: [{ variant: 'danger' }, { disabled: disablePrimaryButton }], diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue index e9e36151fe6..d988ad8d8ca 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue @@ -46,7 +46,6 @@ export default { data() { return { containerRepository: {}, - fetchTagsCount: false, }; }, apollo: { diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue index 3e19a646f53..2d32295b537 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue @@ -1,7 +1,8 @@ <script> -import { GlButton, GlKeysetPagination } from '@gitlab/ui'; import createFlash from '~/flash'; +import { n__ } from '~/locale'; import { joinPaths } from '~/lib/utils/url_utility'; +import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; import { REMOVE_TAGS_BUTTON_TITLE, TAGS_LIST_TITLE, @@ -16,11 +17,10 @@ import TagsLoader from './tags_loader.vue'; export default { name: 'TagsList', components: { - GlButton, - GlKeysetPagination, TagsListRow, EmptyState, TagsLoader, + RegistryList, }, inject: ['config'], props: { @@ -61,11 +61,13 @@ export default { }, data() { return { - selectedItems: {}, containerRepository: {}, }; }, computed: { + listTitle() { + return n__('%d tag', '%d tags', this.tags.length); + }, tags() { return this.containerRepository?.tags?.nodes || []; }, @@ -78,18 +80,9 @@ export default { first: GRAPHQL_PAGE_SIZE, }; }, - hasSelectedItems() { - return this.tags.some((tag) => this.selectedItems[tag.name]); - }, showMultiDeleteButton() { return this.tags.some((tag) => tag.canDelete) && !this.isMobile; }, - multiDeleteButtonIsDisabled() { - return !this.hasSelectedItems || this.disabled; - }, - showPagination() { - return this.tagsPageInfo.hasPreviousPage || this.tagsPageInfo.hasNextPage; - }, hasNoTags() { return this.tags.length === 0; }, @@ -98,19 +91,13 @@ export default { }, }, methods: { - updateSelectedItems(name) { - this.$set(this.selectedItems, name, !this.selectedItems[name]); - }, - mapTagsToBeDleeted(items) { - return this.tags.filter((tag) => items[tag.name]); - }, fetchNextPage() { this.$apollo.queries.containerRepository.fetchMore({ variables: { after: this.tagsPageInfo?.endCursor, first: GRAPHQL_PAGE_SIZE, }, - updateQuery(previousResult, { fetchMoreResult }) { + updateQuery(_, { fetchMoreResult }) { return fetchMoreResult; }, }); @@ -122,7 +109,7 @@ export default { before: this.tagsPageInfo?.startCursor, last: GRAPHQL_PAGE_SIZE, }, - updateQuery(previousResult, { fetchMoreResult }) { + updateQuery(_, { fetchMoreResult }) { return fetchMoreResult; }, }); @@ -137,42 +124,27 @@ export default { <template v-else> <empty-state v-if="hasNoTags" :no-containers-image="config.noContainersImage" /> <template v-else> - <div class="gl-display-flex gl-justify-content-space-between gl-mb-3"> - <h5 data-testid="list-title"> - {{ $options.i18n.TAGS_LIST_TITLE }} - </h5> - - <gl-button - v-if="showMultiDeleteButton" - :disabled="multiDeleteButtonIsDisabled" - category="secondary" - variant="danger" - @click="$emit('delete', mapTagsToBeDleeted(selectedItems))" - > - {{ $options.i18n.REMOVE_TAGS_BUTTON_TITLE }} - </gl-button> - </div> - <tags-list-row - v-for="(tag, index) in tags" - :key="tag.path" - :tag="tag" - :first="index === 0" - :selected="selectedItems[tag.name]" - :is-mobile="isMobile" - :disabled="disabled" - @select="updateSelectedItems(tag.name)" - @delete="$emit('delete', mapTagsToBeDleeted({ [tag.name]: true }))" - /> - <div class="gl-display-flex gl-justify-content-center"> - <gl-keyset-pagination - v-if="showPagination" - :has-next-page="tagsPageInfo.hasNextPage" - :has-previous-page="tagsPageInfo.hasPreviousPage" - class="gl-mt-3" - @prev="fetchPreviousPage" - @next="fetchNextPage" - /> - </div> + <registry-list + :title="listTitle" + :pagination="tagsPageInfo" + :items="tags" + id-property="name" + @prev-page="fetchPreviousPage" + @next-page="fetchNextPage" + @delete="$emit('delete', $event)" + > + <template #default="{ selectItem, isSelected, item, first }"> + <tags-list-row + :tag="item" + :first="first" + :selected="isSelected(item)" + :is-mobile="isMobile" + :disabled="disabled" + @select="selectItem(item)" + @delete="$emit('delete', [item])" + /> + </template> + </registry-list> </template> </template> </div> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql index 01cb7fa1cab..bc34e9b5ef2 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql @@ -9,6 +9,7 @@ query getContainerRepositoriesDetails( $sort: ContainerRepositorySort ) { project(fullPath: $fullPath) @skip(if: $isGroupPage) { + id containerRepositories( name: $name after: $after @@ -24,6 +25,7 @@ query getContainerRepositoriesDetails( } } group(fullPath: $fullPath) @include(if: $isGroupPage) { + id containerRepositories( name: $name after: $after diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql index b5a99fd9ac1..916740f41b8 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql @@ -11,6 +11,7 @@ query getContainerRepositoryDetails($id: ID!) { expirationPolicyStartedAt expirationPolicyCleanupStatus project { + id visibility path containerExpirationPolicy { diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql index a703c2dd0ac..502382010f9 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql @@ -9,6 +9,7 @@ query getContainerRepositoryTags( ) { containerRepository(id: $id) { id + tagsCount tags(after: $after, before: $before, first: $first, last: $last) { nodes { digest diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue index feabc4f770b..bc6e3091f0e 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue @@ -25,9 +25,11 @@ import { UNFINISHED_STATUS, MISSING_OR_DELETED_IMAGE_BREADCRUMB, ROOT_IMAGE_TEXT, + GRAPHQL_PAGE_SIZE, } from '../constants/index'; import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.mutation.graphql'; import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql'; +import getContainerRepositoryTagsQuery from '../graphql/queries/get_container_repository_tags.query.graphql'; export default { name: 'RegistryDetailsPage', @@ -133,8 +135,8 @@ export default { awaitRefetchQueries: true, refetchQueries: [ { - query: getContainerRepositoryDetailsQuery, - variables: this.queryVariables, + query: getContainerRepositoryTagsQuery, + variables: { ...this.queryVariables, first: GRAPHQL_PAGE_SIZE }, }, ], }); diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue index 73b957f42f2..3274de05803 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue @@ -388,6 +388,7 @@ export default { <template #default="{ doDelete }"> <gl-modal ref="deleteModal" + size="sm" modal-id="delete-image-modal" :action-primary="{ text: __('Remove'), attributes: { variant: 'danger' } }" @primary="doDelete" diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue index 71e8cf4f634..eb112238c11 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue @@ -1,11 +1,11 @@ <script> import { GlAlert, + GlEmptyState, GlFormGroup, GlFormInputGroup, GlSkeletonLoader, GlSprintf, - GlEmptyState, } from '@gitlab/ui'; import { s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -36,15 +36,15 @@ export default { proxyNotAvailableText: s__( 'DependencyProxy|Dependency Proxy feature is limited to public groups for now.', ), - proxyDisabledText: s__( - 'DependencyProxy|Dependency Proxy disabled. To enable it, contact the group owner.', - ), proxyImagePrefix: s__('DependencyProxy|Dependency Proxy image prefix'), copyImagePrefixText: s__('DependencyProxy|Copy prefix'), blobCountAndSize: s__('DependencyProxy|Contains %{count} blobs of images (%{size})'), pageTitle: s__('DependencyProxy|Dependency Proxy'), noManifestTitle: s__('DependencyProxy|There are no images in the cache'), }, + links: { + DEPENDENCY_PROXY_DOCS_PATH, + }, data() { return { group: {}, @@ -70,9 +70,7 @@ export default { }, ]; }, - dependencyProxyEnabled() { - return this.group?.dependencyProxySetting?.enabled; - }, + queryVariables() { return { fullPath: this.groupPath, first: GRAPHQL_PAGE_SIZE }; }, @@ -122,7 +120,7 @@ export default { <gl-skeleton-loader v-else-if="$apollo.queries.group.loading" /> - <div v-else-if="dependencyProxyEnabled" data-testid="main-area"> + <div v-else data-testid="main-area"> <gl-form-group :label="$options.i18n.proxyImagePrefix"> <gl-form-input-group readonly @@ -161,8 +159,5 @@ export default { :title="$options.i18n.noManifestTitle" /> </div> - <gl-alert v-else :dismissible="false" data-testid="proxy-disabled"> - {{ $options.i18n.proxyDisabledText }} - </gl-alert> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql index 63d5469c955..9241dccb2d5 100644 --- a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql @@ -8,6 +8,7 @@ query getDependencyProxyDetails( $before: String ) { group(fullPath: $fullPath) { + id dependencyProxyBlobCount dependencyProxyTotalSize dependencyProxyImagePrefix @@ -16,6 +17,7 @@ query getDependencyProxyDetails( } dependencyProxyManifests(after: $after, before: $before, first: $first, last: $last) { nodes { + id createdAt imageName } diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue index 6016757c1b9..f198d2e1bfa 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue @@ -16,10 +16,13 @@ import { s__, __ } from '~/locale'; import TerraformTitle from '~/packages_and_registries/infrastructure_registry/details/components/details_title.vue'; import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue'; import Tracking from '~/tracking'; -import PackageListRow from '~/packages/shared/components/package_list_row.vue'; -import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; -import { TrackingActions, SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; -import { packageTypeToTrackCategory } from '~/packages/shared/utils'; +import PackageListRow from '~/packages_and_registries/infrastructure_registry/shared/package_list_row.vue'; +import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; +import { + TRACKING_ACTIONS, + SHOW_DELETE_SUCCESS_ALERT, +} from '~/packages_and_registries/shared/constants'; +import { TRACK_CATEGORY } from '~/packages_and_registries/infrastructure_registry/shared/constants'; import PackageFiles from './package_files.vue'; import PackageHistory from './package_history.vue'; @@ -44,7 +47,7 @@ export default { GlModal: GlModalDirective, }, mixins: [Tracking.mixin()], - trackingActions: { ...TrackingActions }, + trackingActions: { ...TRACKING_ACTIONS }, data() { return { fileToDelete: null, @@ -68,7 +71,7 @@ export default { }, tracking() { return { - category: packageTypeToTrackCategory(this.packageEntity.package_type), + category: TRACK_CATEGORY, }; }, hasVersions() { @@ -86,7 +89,7 @@ export default { } }, async confirmPackageDeletion() { - this.track(TrackingActions.DELETE_PACKAGE); + this.track(TRACKING_ACTIONS.DELETE_PACKAGE); await this.deletePackage(); const returnTo = !this.groupListUrl || document.referrer.includes(this.projectName) @@ -96,12 +99,12 @@ export default { window.location.replace(`${returnTo}?${modalQuery}`); }, handleFileDelete(file) { - this.track(TrackingActions.REQUEST_DELETE_PACKAGE_FILE); + this.track(TRACKING_ACTIONS.REQUEST_DELETE_PACKAGE_FILE); this.fileToDelete = { ...file }; this.$refs.deleteFileModal.show(); }, confirmFileDelete() { - this.track(TrackingActions.DELETE_PACKAGE_FILE); + this.track(TRACKING_ACTIONS.DELETE_PACKAGE_FILE); this.deletePackageFile(this.fileToDelete.id); this.fileToDelete = null; }, @@ -203,6 +206,7 @@ export default { <gl-modal ref="deleteModal" + size="sm" modal-id="delete-modal" :action-primary="$options.modal.packageDeletePrimaryAction" :action-cancel="$options.modal.cancelAction" @@ -223,6 +227,7 @@ export default { <gl-modal ref="deleteFileModal" + size="sm" modal-id="delete-file-modal" :action-primary="$options.modal.fileDeletePrimaryAction" :action-cancel="$options.modal.cancelAction" diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js index a03fa8d9d63..26d4aa13715 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js @@ -4,7 +4,7 @@ import { DELETE_PACKAGE_ERROR_MESSAGE, DELETE_PACKAGE_FILE_ERROR_MESSAGE, DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, -} from '~/packages/shared/constants'; +} from '~/packages_and_registries/shared/constants'; import { FETCH_PACKAGE_VERSIONS_ERROR } from '../constants'; import * as types from './mutation_types'; diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue index 4928da862ea..c611f92036d 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue @@ -1,7 +1,7 @@ <script> import { mapState, mapActions } from 'vuex'; -import { LIST_KEY_PACKAGE_TYPE } from '~/packages/list/constants'; -import { sortableFields } from '~/packages/list/utils'; +import { LIST_KEY_PACKAGE_TYPE } from '~/packages_and_registries/infrastructure_registry/list/constants'; +import { sortableFields } from '~/packages_and_registries/infrastructure_registry/list/utils'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue'; diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue index 2a479c65d0c..2a479c65d0c 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue diff --git a/app/assets/javascripts/packages/list/components/packages_list.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue index 23ba070aa26..a5f367bc1f6 100644 --- a/app/assets/javascripts/packages/list/components/packages_list.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue @@ -3,10 +3,10 @@ import { GlPagination, GlModal, GlSprintf } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; -import PackagesListRow from '../../shared/components/package_list_row.vue'; -import PackagesListLoader from '../../shared/components/packages_list_loader.vue'; -import { TrackingActions } from '../../shared/constants'; -import { packageTypeToTrackCategory } from '../../shared/utils'; +import PackagesListRow from '~/packages_and_registries/infrastructure_registry/shared/package_list_row.vue'; +import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; +import { TRACKING_ACTIONS } from '~/packages_and_registries/shared/constants'; +import { TRACK_CATEGORY } from '~/packages_and_registries/infrastructure_registry/shared/constants'; export default { components: { @@ -49,27 +49,24 @@ export default { return this.itemToBeDeleted?.name ?? ''; }, tracking() { - const category = this.itemToBeDeleted - ? packageTypeToTrackCategory(this.itemToBeDeleted.package_type) - : undefined; return { - category, + category: TRACK_CATEGORY, }; }, }, methods: { setItemToBeDeleted(item) { this.itemToBeDeleted = { ...item }; - this.track(TrackingActions.REQUEST_DELETE_PACKAGE); + this.track(TRACKING_ACTIONS.REQUEST_DELETE_PACKAGE); this.$refs.packageListDeleteModal.show(); }, deleteItemConfirmation() { this.$emit('package:delete', this.itemToBeDeleted); - this.track(TrackingActions.DELETE_PACKAGE); + this.track(TRACKING_ACTIONS.DELETE_PACKAGE); this.itemToBeDeleted = null; }, deleteItemCanceled() { - this.track(TrackingActions.CANCEL_DELETE_PACKAGE); + this.track(TRACKING_ACTIONS.CANCEL_DELETE_PACKAGE); this.itemToBeDeleted = null; }, }, @@ -111,6 +108,7 @@ export default { <gl-modal ref="packageListDeleteModal" + size="sm" modal-id="confirm-delete-pacakge" ok-variant="danger" @ok="deleteItemConfirmation" diff --git a/app/assets/javascripts/packages/list/components/packages_list_app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue index 31d90fa4dee..462618a7f12 100644 --- a/app/assets/javascripts/packages/list/components/packages_list_app.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue @@ -4,13 +4,16 @@ import { mapActions, mapState } from 'vuex'; import createFlash from '~/flash'; import { historyReplaceState } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; -import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; -import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; +import { + SHOW_DELETE_SUCCESS_ALERT, + FILTERED_SEARCH_TERM, +} from '~/packages_and_registries/shared/constants'; + import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils'; -import InfrastructureTitle from '~/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue'; -import InfrastructureSearch from '~/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue'; -import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants'; -import PackageList from './packages_list.vue'; +import InfrastructureTitle from '~/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue'; +import InfrastructureSearch from '~/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue'; +import PackageList from '~/packages_and_registries/infrastructure_registry/list/components/packages_list.vue'; +import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages_and_registries/infrastructure_registry/list/constants'; export default { components: { diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/constants.js index 4f5071e784b..7af3fc1c2db 100644 --- a/app/assets/javascripts/packages/list/constants.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/constants.js @@ -1,10 +1,8 @@ -import { __, s__ } from '~/locale'; -import { PackageType } from '../shared/constants'; +import { __ } from '~/locale'; export const FETCH_PACKAGES_LIST_ERROR_MESSAGE = __( 'Something went wrong while fetching the packages list.', ); -export const FETCH_PACKAGE_ERROR_MESSAGE = __('Something went wrong while fetching the package.'); export const DELETE_PACKAGE_SUCCESS_MESSAGE = __('Package deleted successfully'); export const DEFAULT_PAGE = 1; @@ -17,14 +15,12 @@ export const LIST_KEY_PROJECT = 'project_path'; export const LIST_KEY_VERSION = 'version'; export const LIST_KEY_PACKAGE_TYPE = 'type'; export const LIST_KEY_CREATED_AT = 'created_at'; -export const LIST_KEY_ACTIONS = 'actions'; export const LIST_LABEL_NAME = __('Name'); export const LIST_LABEL_PROJECT = __('Project'); export const LIST_LABEL_VERSION = __('Version'); export const LIST_LABEL_PACKAGE_TYPE = __('Type'); export const LIST_LABEL_CREATED_AT = __('Published'); -export const LIST_LABEL_ACTIONS = ''; // The following is not translated because it is used to build a JavaScript exception error message export const MISSING_DELETE_PATH_ERROR = 'Missing delete_api_path link'; @@ -52,48 +48,4 @@ export const SORT_FIELDS = [ }, ]; -export const PACKAGE_TYPES = [ - { - title: s__('PackageRegistry|Composer'), - type: PackageType.COMPOSER, - }, - { - title: s__('PackageRegistry|Conan'), - type: PackageType.CONAN, - }, - { - title: s__('PackageRegistry|Generic'), - type: PackageType.GENERIC, - }, - - { - title: s__('PackageRegistry|Maven'), - type: PackageType.MAVEN, - }, - { - title: s__('PackageRegistry|npm'), - type: PackageType.NPM, - }, - { - title: s__('PackageRegistry|NuGet'), - type: PackageType.NUGET, - }, - { - title: s__('PackageRegistry|PyPI'), - type: PackageType.PYPI, - }, - { - title: s__('PackageRegistry|RubyGems'), - type: PackageType.RUBYGEMS, - }, - { - title: s__('PackageRegistry|Debian'), - type: PackageType.DEBIAN, - }, - { - title: s__('PackageRegistry|Helm'), - type: PackageType.HELM, - }, -]; - export const TERRAFORM_SEARCH_TYPE = Object.freeze({ value: { data: 'terraform_module' } }); diff --git a/app/assets/javascripts/packages/list/stores/actions.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js index 81f587971c2..488860e5bc2 100644 --- a/app/assets/javascripts/packages/list/stores/actions.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js @@ -1,7 +1,7 @@ import Api from '~/api'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages/shared/constants'; +import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages_and_registries/shared/constants'; import { FETCH_PACKAGES_LIST_ERROR_MESSAGE, DELETE_PACKAGE_SUCCESS_MESSAGE, diff --git a/app/assets/javascripts/packages/list/stores/getters.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/getters.js index 482c111b58b..5989303280e 100644 --- a/app/assets/javascripts/packages/list/stores/getters.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/getters.js @@ -1,4 +1,4 @@ -import { beautifyPath } from '../../shared/utils'; +import { beautifyPath } from '~/packages_and_registries/shared/utils'; import { LIST_KEY_PROJECT } from '../constants'; export default (state) => diff --git a/app/assets/javascripts/packages/list/stores/index.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/index.js index 1d6a4bf831d..1d6a4bf831d 100644 --- a/app/assets/javascripts/packages/list/stores/index.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/index.js diff --git a/app/assets/javascripts/packages/list/stores/mutation_types.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/mutation_types.js index 561ad97f7e3..561ad97f7e3 100644 --- a/app/assets/javascripts/packages/list/stores/mutation_types.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/mutation_types.js diff --git a/app/assets/javascripts/packages/list/stores/mutations.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/mutations.js index 98165e581b0..98165e581b0 100644 --- a/app/assets/javascripts/packages/list/stores/mutations.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/mutations.js diff --git a/app/assets/javascripts/packages/list/stores/state.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/state.js index 60f02eddc9f..60f02eddc9f 100644 --- a/app/assets/javascripts/packages/list/stores/state.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/state.js diff --git a/app/assets/javascripts/packages/list/utils.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/utils.js index 537b30d2ca4..537b30d2ca4 100644 --- a/app/assets/javascripts/packages/list/utils.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/utils.js diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js index 7e6e98f4fb5..1467218dd41 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import { s__ } from '~/locale'; -import PackagesListApp from '~/packages/list/components/packages_list_app.vue'; -import { createStore } from '~/packages/list/stores'; +import PackagesListApp from '~/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue'; +import { createStore } from '~/packages_and_registries/infrastructure_registry/list/stores'; import Translate from '~/vue_shared/translate'; Vue.use(Translate); @@ -18,9 +18,6 @@ export default () => { PackagesListApp, }, provide: { - titleComponent: 'InfrastructureTitle', - searchComponent: 'InfrastructureSearch', - iconComponent: 'InfrastructureIconAndName', emptyPageTitle: s__('InfrastructureRegistry|You have no Terraform modules in your project'), noResultsText: s__( 'InfrastructureRegistry|Terraform modules are the main way to package and reuse resource configurations with Terraform. Learn more about how to %{noPackagesLinkStart}create Terraform modules%{noPackagesLinkEnd} in GitLab.', diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/constants.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/constants.js new file mode 100644 index 00000000000..ab52ec01d40 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/constants.js @@ -0,0 +1 @@ +export const TRACK_CATEGORY = 'UI::TerraformPackages'; diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/infrastructure_icon_and_name.vue index 3100a1a7296..3100a1a7296 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/infrastructure_icon_and_name.vue diff --git a/app/assets/javascripts/packages/shared/components/package_list_row.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue index eee0e470c7b..3c6b8344c34 100644 --- a/app/assets/javascripts/packages/shared/components/package_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue @@ -3,11 +3,14 @@ import { GlButton, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gi import { s__ } from '~/locale'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; -import { PACKAGE_ERROR_STATUS, PACKAGE_DEFAULT_STATUS } from '../constants'; -import { getPackageTypeLabel } from '../utils'; -import PackagePath from './package_path.vue'; -import PackageTags from './package_tags.vue'; -import PublishMethod from './publish_method.vue'; +import { + PACKAGE_ERROR_STATUS, + PACKAGE_DEFAULT_STATUS, +} from '~/packages_and_registries/shared/constants'; +import PackagePath from '~/packages_and_registries/shared/components/package_path.vue'; +import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; +import PublishMethod from '~/packages_and_registries/shared/components/publish_method.vue'; +import InfrastructureIconAndName from '~/packages_and_registries/infrastructure_registry/shared/infrastructure_icon_and_name.vue'; export default { name: 'PackageListRow', @@ -20,23 +23,12 @@ export default { PackagePath, PublishMethod, ListItem, - PackageIconAndName: () => - import(/* webpackChunkName: 'package_registry_components' */ './package_icon_and_name.vue'), - InfrastructureIconAndName: () => - import( - /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue' - ), + InfrastructureIconAndName, }, directives: { GlTooltip: GlTooltipDirective, }, mixins: [timeagoMixin], - inject: { - iconComponent: { - from: 'iconComponent', - default: 'PackageIconAndName', - }, - }, props: { packageEntity: { type: Object, @@ -63,9 +55,6 @@ export default { }, }, computed: { - packageType() { - return getPackageTypeLabel(this.packageEntity.package_type); - }, hasPipeline() { return Boolean(this.packageEntity.pipeline); }, @@ -130,9 +119,7 @@ export default { </gl-sprintf> </div> - <component :is="iconComponent" v-if="showPackageType"> - {{ packageType }} - </component> + <infrastructure-icon-and-name v-if="showPackageType" /> <package-path v-if="hasProjectLink" diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue index bcbeec72961..d49c1be5202 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue @@ -15,7 +15,7 @@ import { convertToGraphQLId } from '~/graphql_shared/utils'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { objectToQuery } from '~/lib/utils/url_utility'; import { s__, __ } from '~/locale'; -import { packageTypeToTrackCategory } from '~/packages/shared/utils'; +import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils'; import AdditionalMetadata from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue'; import DependencyRow from '~/packages_and_registries/package_registry/components/details/dependency_row.vue'; import InstallationCommands from '~/packages_and_registries/package_registry/components/details/installation_commands.vue'; @@ -304,6 +304,7 @@ export default { <template #default="{ deletePackage }"> <gl-modal ref="deleteModal" + size="sm" modal-id="delete-modal" data-testid="delete-modal" :action-primary="$options.modal.packageDeletePrimaryAction" @@ -327,6 +328,7 @@ export default { <gl-modal ref="deleteFileModal" + size="sm" modal-id="delete-file-modal" :action-primary="$options.modal.fileDeletePrimaryAction" :action-cancel="$options.modal.cancelAction" diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue index 44d7807639d..118c509828c 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue @@ -3,7 +3,7 @@ import { GlIcon, GlSprintf, GlBadge, GlResizeObserverDirective } from '@gitlab/u import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { __ } from '~/locale'; -import PackageTags from '~/packages/shared/components/package_tags.vue'; +import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; import { PACKAGE_TYPE_NUGET } from '~/packages_and_registries/package_registry/constants'; import { getPackageTypeLabel } from '~/packages_and_registries/package_registry/utils'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue index d218a405af6..1afd1b69db0 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue @@ -1,8 +1,8 @@ <script> import { GlLink, GlSprintf, GlTruncate } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import PackageTags from '~/packages/shared/components/package_tags.vue'; -import PublishMethod from '~/packages/shared/components/publish_method.vue'; +import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; +import PublishMethod from '~/packages_and_registries/shared/components/publish_method.vue'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { PACKAGE_DEFAULT_STATUS } from '../../constants'; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue index 195ff7af583..6fd96c0654f 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue @@ -1,16 +1,16 @@ <script> import { GlButton, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import { PACKAGE_ERROR_STATUS, PACKAGE_DEFAULT_STATUS, } from '~/packages_and_registries/package_registry/constants'; -import { getPackageTypeLabel } from '~/packages/shared/utils'; -import PackagePath from '~/packages/shared/components/package_path.vue'; -import PackageTags from '~/packages/shared/components/package_tags.vue'; +import { getPackageTypeLabel } from '~/packages_and_registries/package_registry/utils'; +import PackagePath from '~/packages_and_registries/shared/components/package_path.vue'; +import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue'; import PublishMethod from '~/packages_and_registries/package_registry/components/list/publish_method.vue'; -import PackageIconAndName from '~/packages/shared/components/package_icon_and_name.vue'; +import PackageIconAndName from '~/packages_and_registries/shared/components/package_icon_and_name.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -40,7 +40,7 @@ export default { }, computed: { packageType() { - return getPackageTypeLabel(this.packageEntity.packageType.toLowerCase()); + return getPackageTypeLabel(this.packageEntity.packageType); }, packageLink() { const { project, id } = this.packageEntity; @@ -64,6 +64,7 @@ export default { }, i18n: { erroredPackageText: s__('PackageRegistry|Invalid Package: failed metadata extraction'), + createdAt: __('Created %{timestamp}'), }, }; </script> @@ -127,8 +128,8 @@ export default { </template> <template #right-secondary> - <span> - <gl-sprintf :message="__('Created %{timestamp}')"> + <span data-testid="created-date"> + <gl-sprintf :message="$options.i18n.createdAt"> <template #timestamp> <timeago-tooltip :time="packageEntity.createdAt" /> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue index 2a946544c2f..298ed9bccdb 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue @@ -2,7 +2,7 @@ import { GlModal, GlSprintf, GlKeysetPagination } from '@gitlab/ui'; import { s__ } from '~/locale'; import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue'; -import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; +import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; import { DELETE_PACKAGE_TRACKING_ACTION, REQUEST_DELETE_PACKAGE_TRACKING_ACTION, @@ -124,6 +124,7 @@ export default { <gl-modal v-model="showDeleteModal" modal-id="confirm-delete-pacakge" + size="sm" ok-variant="danger" @ok="deleteItemConfirmation" @cancel="deleteItemCanceled" diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js index 9fd8880861c..ab6541e4264 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js @@ -1,4 +1,15 @@ import { s__, __ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +export { + DELETE_PACKAGE_TRACKING_ACTION, + REQUEST_DELETE_PACKAGE_TRACKING_ACTION, + CANCEL_DELETE_PACKAGE_TRACKING_ACTION, + PULL_PACKAGE_TRACKING_ACTION, + DELETE_PACKAGE_FILE_TRACKING_ACTION, + REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION, + CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION, +} from '~/packages_and_registries/shared/constants'; export const PACKAGE_TYPE_CONAN = 'CONAN'; export const PACKAGE_TYPE_MAVEN = 'MAVEN'; @@ -11,14 +22,6 @@ export const PACKAGE_TYPE_GENERIC = 'GENERIC'; export const PACKAGE_TYPE_DEBIAN = 'DEBIAN'; export const PACKAGE_TYPE_HELM = 'HELM'; -export const DELETE_PACKAGE_TRACKING_ACTION = 'delete_package'; -export const REQUEST_DELETE_PACKAGE_TRACKING_ACTION = 'request_delete_package'; -export const CANCEL_DELETE_PACKAGE_TRACKING_ACTION = 'cancel_delete_package'; -export const PULL_PACKAGE_TRACKING_ACTION = 'pull_package'; -export const DELETE_PACKAGE_FILE_TRACKING_ACTION = 'delete_package_file'; -export const REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'request_delete_package_file'; -export const CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'cancel_delete_package_file'; - export const TRACKING_LABEL_CODE_INSTRUCTION = 'code_instruction'; export const TRACKING_LABEL_CONAN_INSTALLATION = 'conan_installation'; export const TRACKING_LABEL_MAVEN_INSTALLATION = 'maven_installation'; @@ -134,3 +137,8 @@ export const PACKAGE_TYPES = [ s__('PackageRegistry|Debian'), s__('PackageRegistry|Helm'), ]; + +// links + +export const EMPTY_LIST_HELP_URL = helpPagePath('user/packages/package_registry/index'); +export const PACKAGE_HELP_URL = helpPagePath('user/packages/index'); diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql index aaf0eb54aff..66315fda9e9 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql @@ -7,20 +7,24 @@ fragment PackageData on Package { status tags { nodes { + id name } } - pipelines { + pipelines(last: 1) { nodes { + id sha ref commitPath user { + id name } } } project { + id fullPath webUrl } diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql index 14aa14e9822..08ea0938a59 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql @@ -8,6 +8,7 @@ query getPackageDetails($id: ID!) { updatedAt status project { + id path } tags(first: 10) { @@ -25,9 +26,11 @@ query getPackageDetails($id: ID!) { commitPath path user { + id name } project { + id name webUrl } @@ -86,15 +89,18 @@ query getPackageDetails($id: ID!) { } } ... on PypiMetadata { + id requiredPython } ... on ConanMetadata { + id packageChannel packageUsername recipe recipePath } ... on MavenMetadata { + id appName appGroup appVersion @@ -102,6 +108,7 @@ query getPackageDetails($id: ID!) { } ... on NugetMetadata { + id iconUrl licenseUrl projectUrl diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql index e3115365f8b..4b913590949 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql @@ -14,6 +14,7 @@ query getPackages( $before: String ) { project(fullPath: $fullPath) @skip(if: $isGroupPage) { + id packages( sort: $sort packageName: $packageName @@ -33,6 +34,7 @@ query getPackages( } } group(fullPath: $fullPath) @include(if: $isGroupPage) { + id packages( sort: $groupSort packageName: $packageName diff --git a/app/assets/javascripts/packages_and_registries/package_registry/index.js b/app/assets/javascripts/packages_and_registries/package_registry/index.js new file mode 100644 index 00000000000..7ec931ff9a0 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/index.js @@ -0,0 +1,30 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import { apolloProvider } from '~/packages_and_registries/package_registry/graphql/index'; +import PackageRegistry from '~/packages_and_registries/package_registry/pages/index.vue'; +import createRouter from './router'; + +Vue.use(Translate); + +export default () => { + const el = document.getElementById('js-vue-packages-list'); + const { endpoint, resourceId, fullPath, pageType, emptyListIllustration } = el.dataset; + const router = createRouter(endpoint); + + const isGroupPage = pageType === 'groups'; + + return new Vue({ + el, + router, + apolloProvider, + provide: { + resourceId, + fullPath, + emptyListIllustration, + isGroupPage, + }, + render(createElement) { + return createElement(PackageRegistry); + }, + }); +}; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/index.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/index.vue new file mode 100644 index 00000000000..a14d0c32cbe --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/index.vue @@ -0,0 +1,5 @@ +<template> + <div> + <router-view /> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.js b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.js deleted file mode 100644 index d797a0a5327..00000000000 --- a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.js +++ /dev/null @@ -1,24 +0,0 @@ -import Vue from 'vue'; -import Translate from '~/vue_shared/translate'; -import { apolloProvider } from '~/packages_and_registries/package_registry/graphql/index'; -import PackagesListApp from '../components/list/app.vue'; - -Vue.use(Translate); - -export default () => { - const el = document.getElementById('js-vue-packages-list'); - - const isGroupPage = el.dataset.pageType === 'groups'; - - return new Vue({ - el, - apolloProvider, - provide: { - ...el.dataset, - isGroupPage, - }, - render(createElement) { - return createElement(PackagesListApp); - }, - }); -}; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue index 11eeaf933ff..38df701157a 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue @@ -3,19 +3,21 @@ import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; import createFlash from '~/flash'; import { historyReplaceState } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; -import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; +import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages_and_registries/shared/constants'; import { PROJECT_RESOURCE_TYPE, GROUP_RESOURCE_TYPE, GRAPHQL_PAGE_SIZE, DELETE_PACKAGE_SUCCESS_MESSAGE, + EMPTY_LIST_HELP_URL, + PACKAGE_HELP_URL, } from '~/packages_and_registries/package_registry/constants'; import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql'; import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue'; -import PackageTitle from './package_title.vue'; -import PackageSearch from './package_search.vue'; -import PackageList from './packages_list.vue'; +import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue'; +import PackageSearch from '~/packages_and_registries/package_registry/components/list/package_search.vue'; +import PackageList from '~/packages_and_registries/package_registry/components/list/packages_list.vue'; export default { components: { @@ -27,13 +29,7 @@ export default { PackageSearch, DeletePackage, }, - inject: [ - 'packageHelpUrl', - 'emptyListIllustration', - 'emptyListHelpUrl', - 'isGroupPage', - 'fullPath', - ], + inject: ['emptyListIllustration', 'isGroupPage', 'fullPath'], data() { return { packages: {}, @@ -156,12 +152,16 @@ export default { 'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.', ), }, + links: { + EMPTY_LIST_HELP_URL, + PACKAGE_HELP_URL, + }, }; </script> <template> <div> - <package-title :help-url="packageHelpUrl" :count="packagesCount" /> + <package-title :help-url="$options.links.PACKAGE_HELP_URL" :count="packagesCount" /> <package-search @update="handleSearchUpdate" /> <delete-package @@ -185,7 +185,9 @@ export default { <gl-sprintf v-if="hasFilters" :message="$options.i18n.widenFilters" /> <gl-sprintf v-else :message="$options.i18n.noResultsText"> <template #noPackagesLink="{ content }"> - <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link> + <gl-link :href="$options.links.EMPTY_LIST_HELP_URL" target="_blank">{{ + content + }}</gl-link> </template> </gl-sprintf> </template> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/router.js b/app/assets/javascripts/packages_and_registries/package_registry/router.js new file mode 100644 index 00000000000..ea5b740e879 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/package_registry/router.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import List from '~/packages_and_registries/package_registry/pages/list.vue'; + +Vue.use(VueRouter); + +export default function createRouter(base) { + const router = new VueRouter({ + base, + mode: 'history', + routes: [ + { + name: 'list', + path: '/', + component: List, + }, + ], + }); + + return router; +} diff --git a/app/assets/javascripts/packages_and_registries/settings/group/bundle.js b/app/assets/javascripts/packages_and_registries/settings/group/bundle.js index 9b5a0d221b8..85a7aeb5561 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/bundle.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/bundle.js @@ -18,9 +18,10 @@ export default () => { el, apolloProvider, provide: { + groupPath: el.dataset.groupPath, + groupDependencyProxyPath: el.dataset.groupDependencyProxyPath, defaultExpanded: parseBoolean(el.dataset.defaultExpanded), dependencyProxyAvailable: parseBoolean(el.dataset.dependencyProxyAvailable), - groupPath: el.dataset.groupPath, }, render(createElement) { return createElement(SettingsApp); diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue index 5815c6393a7..fd62fe144b2 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue @@ -2,9 +2,14 @@ import { GlToggle, GlSprintf, GlLink } from '@gitlab/ui'; import { s__ } from '~/locale'; import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; +import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue'; import updateDependencyProxySettings from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql'; +import updateDependencyProxyImageTtlGroupPolicy from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_image_ttl_group_policy.mutation.graphql'; import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update'; -import { updateGroupDependencyProxySettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; +import { + updateGroupDependencyProxySettingsOptimisticResponse, + updateDependencyProxyImageTtlGroupPolicyOptimisticResponse, +} from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; import { DEPENDENCY_PROXY_HEADER, @@ -19,21 +24,34 @@ export default { GlSprintf, GlLink, SettingsBlock, + SettingsTitles, }, i18n: { DEPENDENCY_PROXY_HEADER, DEPENDENCY_PROXY_SETTINGS_DESCRIPTION, - label: s__('DependencyProxy|Enable Proxy'), + enabledProxyLabel: s__('DependencyProxy|Enable Dependency Proxy'), + enabledProxyHelpText: s__( + 'DependencyProxy|To see the image prefix and what is in the cache, visit the %{linkStart}Dependency Proxy%{linkEnd}', + ), + storageSettingsTitle: s__('DependencyProxy|Storage settings'), + ttlPolicyEnabledLabel: s__('DependencyProxy|Clear the Dependency Proxy cache automatically'), + ttlPolicyEnabledHelpText: s__( + 'DependencyProxy|When enabled, images older than 90 days will be removed from the cache.', + ), }, links: { DEPENDENCY_PROXY_DOCS_PATH, }, - inject: ['defaultExpanded', 'groupPath'], + inject: ['defaultExpanded', 'groupPath', 'groupDependencyProxyPath'], props: { dependencyProxySettings: { type: Object, required: true, }, + dependencyProxyImageTtlPolicy: { + type: Object, + required: true, + }, isLoading: { type: Boolean, required: false, @@ -49,26 +67,35 @@ export default { this.updateSettings({ enabled }); }, }, + ttlEnabled: { + get() { + return this.dependencyProxyImageTtlPolicy.enabled; + }, + set(enabled) { + const payload = { + enabled, + ttl: 90, // hardocded TTL for the MVC version + }; + this.updateDependencyProxyImageTtlGroupPolicy(payload); + }, + }, + helpText() { + return this.enabled ? this.$options.i18n.enabledProxyHelpText : ''; + }, }, methods: { - async updateSettings(payload) { + mutationVariables(payload) { + return { + input: { + groupPath: this.groupPath, + ...payload, + }, + }; + }, + async executeMutation(config, resource) { try { - const { data } = await this.$apollo.mutate({ - mutation: updateDependencyProxySettings, - variables: { - input: { - groupPath: this.groupPath, - ...payload, - }, - }, - update: updateGroupPackageSettings(this.groupPath), - optimisticResponse: updateGroupDependencyProxySettingsOptimisticResponse({ - ...this.dependencyProxySettings, - ...payload, - }), - }); - - if (data.updateDependencyProxySettings?.errors?.length > 0) { + const { data } = await this.$apollo.mutate(config); + if (data[resource]?.errors.length > 0) { throw new Error(); } else { this.$emit('success'); @@ -77,6 +104,32 @@ export default { this.$emit('error'); } }, + async updateSettings(payload) { + const apolloConfig = { + mutation: updateDependencyProxySettings, + variables: this.mutationVariables(payload), + update: updateGroupPackageSettings(this.groupPath), + optimisticResponse: updateGroupDependencyProxySettingsOptimisticResponse({ + ...this.dependencyProxySettings, + ...payload, + }), + }; + + this.executeMutation(apolloConfig, 'updateDependencyProxySettings'); + }, + async updateDependencyProxyImageTtlGroupPolicy(payload) { + const apolloConfig = { + mutation: updateDependencyProxyImageTtlGroupPolicy, + variables: this.mutationVariables(payload), + update: updateGroupPackageSettings(this.groupPath), + optimisticResponse: updateDependencyProxyImageTtlGroupPolicyOptimisticResponse({ + ...this.dependencyProxyImageTtlPolicy, + ...payload, + }), + }; + + this.executeMutation(apolloConfig, 'updateDependencyProxyImageTtlGroupPolicy'); + }, }, }; </script> @@ -91,7 +144,11 @@ export default { <span data-testid="description"> <gl-sprintf :message="$options.i18n.DEPENDENCY_PROXY_SETTINGS_DESCRIPTION"> <template #docLink="{ content }"> - <gl-link :href="$options.links.DEPENDENCY_PROXY_DOCS_PATH">{{ content }}</gl-link> + <gl-link + data-testid="description-link" + :href="$options.links.DEPENDENCY_PROXY_DOCS_PATH" + >{{ content }}</gl-link + > </template> </gl-sprintf> </span> @@ -101,9 +158,31 @@ export default { <gl-toggle v-model="enabled" :disabled="isLoading" - :label="$options.i18n.label" + :label="$options.i18n.enabledProxyLabel" + :help="helpText" data-qa-selector="dependency_proxy_setting_toggle" data-testid="dependency-proxy-setting-toggle" + > + <template #help> + <span class="gl-overflow-break-word gl-max-w-100vw gl-display-inline-block"> + <gl-sprintf :message="$options.i18n.enabledProxyHelpText"> + <template #link="{ content }"> + <gl-link data-testid="toggle-help-link" :href="groupDependencyProxyPath">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </span> + </template> + </gl-toggle> + + <settings-titles :title="$options.i18n.storageSettingsTitle" class="gl-my-6" /> + <gl-toggle + v-model="ttlEnabled" + :disabled="isLoading" + :label="$options.i18n.ttlPolicyEnabledLabel" + :help="$options.i18n.ttlPolicyEnabledHelpText" + data-testid="dependency-proxy-ttl-policies-toggle" /> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue index b45cedcdd66..64c12b4be6a 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue @@ -37,6 +37,9 @@ export default { dependencyProxySettings() { return this.group?.dependencyProxySetting || {}; }, + dependencyProxyImageTtlPolicy() { + return this.group?.dependencyProxyImageTtlPolicy || {}; + }, isLoading() { return this.$apollo.queries.group.loading; }, @@ -82,6 +85,7 @@ export default { <dependency-proxy-settings v-if="dependencyProxyAvailable" :dependency-proxy-settings="dependencyProxySettings" + :dependency-proxy-image-ttl-policy="dependencyProxyImageTtlPolicy" :is-loading="isLoading" @success="handleSuccess" @error="handleError" diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue index 3f0ab7686e5..1e93875c1e3 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue @@ -8,7 +8,8 @@ export default { }, subTitle: { type: String, - required: true, + required: false, + default: '', }, }, }; @@ -16,10 +17,10 @@ export default { <template> <div> - <h5 class="gl-border-b-solid gl-border-b-1 gl-border-gray-200"> + <h5 class="gl-border-b-solid gl-border-b-1 gl-border-gray-200 gl-pb-3"> {{ title }} </h5> - <p>{{ subTitle }}</p> + <p v-if="subTitle">{{ subTitle }}</p> <slot></slot> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_image_ttl_group_policy.mutation.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_image_ttl_group_policy.mutation.graphql new file mode 100644 index 00000000000..81250f52dfb --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_image_ttl_group_policy.mutation.graphql @@ -0,0 +1,11 @@ +mutation updateDependencyProxyImageTtlGroupPolicy( + $input: UpdateDependencyProxyImageTtlGroupPolicyInput! +) { + updateDependencyProxyImageTtlGroupPolicy(input: $input) { + dependencyProxyImageTtlPolicy { + enabled + ttl + } + errors + } +} diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql index d3edebfbe20..404d9d26d49 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql +++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql @@ -1,8 +1,13 @@ query getGroupPackagesSettings($fullPath: ID!) { group(fullPath: $fullPath) { + id dependencyProxySetting { enabled } + dependencyProxyImageTtlPolicy { + ttl + enabled + } packageSettings { mavenDuplicatesAllowed mavenDuplicateExceptionRegex diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js index fe94203f51b..c7b0899fa4c 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js @@ -19,6 +19,11 @@ export const updateGroupPackageSettings = (fullPath) => (client, { data: updated ...updatedData.updateDependencyProxySettings.dependencyProxySetting, }; } + if (updatedData.updateDependencyProxyImageTtlGroupPolicy) { + draftState.group.dependencyProxyImageTtlPolicy = { + ...updatedData.updateDependencyProxyImageTtlGroupPolicy.dependencyProxyImageTtlPolicy, + }; + } }); client.writeQuery({ diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js index a30d8ca0b81..92f6e117911 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js @@ -21,3 +21,15 @@ export const updateGroupDependencyProxySettingsOptimisticResponse = (changes) => }, }, }); + +export const updateDependencyProxyImageTtlGroupPolicyOptimisticResponse = (changes) => ({ + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Mutation', + updateDependencyProxyImageTtlGroupPolicy: { + __typename: 'UpdateDependencyProxyImageTtlGroupPolicyPayload', + errors: [], + dependencyProxyImageTtlPolicy: { + ...changes, + }, + }, +}); diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql index c171be0ad07..6a862da92df 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql +++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql @@ -2,6 +2,7 @@ query getProjectExpirationPolicy($projectPath: ID!) { project(fullPath: $projectPath) { + id containerExpirationPolicy { ...ContainerExpirationPolicyFields } diff --git a/app/assets/javascripts/packages/shared/components/package_icon_and_name.vue b/app/assets/javascripts/packages_and_registries/shared/components/package_icon_and_name.vue index 105f7bbe132..105f7bbe132 100644 --- a/app/assets/javascripts/packages/shared/components/package_icon_and_name.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/package_icon_and_name.vue diff --git a/app/assets/javascripts/packages/shared/components/package_path.vue b/app/assets/javascripts/packages_and_registries/shared/components/package_path.vue index 6fb001e5e92..6fb001e5e92 100644 --- a/app/assets/javascripts/packages/shared/components/package_path.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/package_path.vue diff --git a/app/assets/javascripts/packages/shared/components/package_tags.vue b/app/assets/javascripts/packages_and_registries/shared/components/package_tags.vue index 5ec950e4d45..5ec950e4d45 100644 --- a/app/assets/javascripts/packages/shared/components/package_tags.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/package_tags.vue diff --git a/app/assets/javascripts/packages/shared/components/packages_list_loader.vue b/app/assets/javascripts/packages_and_registries/shared/components/packages_list_loader.vue index cf555f46f8c..cf555f46f8c 100644 --- a/app/assets/javascripts/packages/shared/components/packages_list_loader.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/packages_list_loader.vue diff --git a/app/assets/javascripts/packages/shared/components/publish_method.vue b/app/assets/javascripts/packages_and_registries/shared/components/publish_method.vue index 8a66a33f2ab..8a66a33f2ab 100644 --- a/app/assets/javascripts/packages/shared/components/publish_method.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/publish_method.vue diff --git a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue new file mode 100644 index 00000000000..79381f82009 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue @@ -0,0 +1,124 @@ +<script> +import { GlButton, GlFormCheckbox, GlKeysetPagination } from '@gitlab/ui'; +import { filter } from 'lodash'; +import { __ } from '~/locale'; + +export default { + name: 'RegistryList', + components: { + GlButton, + GlFormCheckbox, + GlKeysetPagination, + }, + props: { + title: { + type: String, + required: true, + }, + isLoading: { + type: Boolean, + default: false, + required: false, + }, + hiddenDelete: { + type: Boolean, + default: false, + required: false, + }, + pagination: { + type: Object, + required: false, + default: () => ({}), + }, + items: { + type: Array, + required: false, + default: () => [], + }, + idProperty: { + type: String, + required: false, + default: 'id', + }, + }, + data() { + return { + selectedReferences: {}, + }; + }, + computed: { + showPagination() { + return this.pagination.hasPreviousPage || this.pagination.hasNextPage; + }, + disableDeleteButton() { + return this.isLoading || filter(this.selectedReferences).length === 0; + }, + selectedItems() { + return this.items.filter(this.isSelected); + }, + selectAll: { + get() { + return this.items.every(this.isSelected); + }, + set(value) { + this.items.forEach((item) => { + const id = item[this.idProperty]; + this.$set(this.selectedReferences, id, value); + }); + }, + }, + }, + methods: { + selectItem(item) { + const id = item[this.idProperty]; + this.$set(this.selectedReferences, id, !this.selectedReferences[id]); + }, + isSelected(item) { + const id = item[this.idProperty]; + return this.selectedReferences[id]; + }, + }, + i18n: { + deleteSelected: __('Delete Selected'), + }, +}; +</script> + +<template> + <div> + <div class="gl-display-flex gl-justify-content-space-between gl-mb-3 gl-align-items-center"> + <gl-form-checkbox v-if="!hiddenDelete" v-model="selectAll" class="gl-ml-2 gl-pt-2"> + <span class="gl-font-weight-bold">{{ title }}</span> + </gl-form-checkbox> + + <gl-button + v-if="!hiddenDelete" + :disabled="disableDeleteButton" + category="secondary" + variant="danger" + @click="$emit('delete', selectedItems)" + > + {{ $options.i18n.deleteSelected }} + </gl-button> + </div> + + <div v-for="(item, index) in items" :key="index"> + <slot + :select-item="selectItem" + :is-selected="isSelected" + :item="item" + :first="index === 0" + ></slot> + </div> + + <div class="gl-display-flex gl-justify-content-center"> + <gl-keyset-pagination + v-if="showPagination" + v-bind="pagination" + class="gl-mt-3" + @prev="$emit('prev-page')" + @next="$emit('next-page')" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/shared/constants.js b/app/assets/javascripts/packages_and_registries/shared/constants.js index 7d2971bd8c7..afc72a2c627 100644 --- a/app/assets/javascripts/packages_and_registries/shared/constants.js +++ b/app/assets/javascripts/packages_and_registries/shared/constants.js @@ -1,3 +1,39 @@ +import { s__ } from '~/locale'; + export const FILTERED_SEARCH_TERM = 'filtered-search-term'; export const FILTERED_SEARCH_TYPE = 'type'; export const HISTORY_PIPELINES_LIMIT = 5; + +export const DELETE_PACKAGE_TRACKING_ACTION = 'delete_package'; +export const REQUEST_DELETE_PACKAGE_TRACKING_ACTION = 'request_delete_package'; +export const CANCEL_DELETE_PACKAGE_TRACKING_ACTION = 'cancel_delete_package'; +export const PULL_PACKAGE_TRACKING_ACTION = 'pull_package'; +export const DELETE_PACKAGE_FILE_TRACKING_ACTION = 'delete_package_file'; +export const REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'request_delete_package_file'; +export const CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'cancel_delete_package_file'; + +export const TRACKING_ACTIONS = { + DELETE_PACKAGE: DELETE_PACKAGE_TRACKING_ACTION, + REQUEST_DELETE_PACKAGE: REQUEST_DELETE_PACKAGE_TRACKING_ACTION, + CANCEL_DELETE_PACKAGE: CANCEL_DELETE_PACKAGE_TRACKING_ACTION, + PULL_PACKAGE: PULL_PACKAGE_TRACKING_ACTION, + DELETE_PACKAGE_FILE: DELETE_PACKAGE_FILE_TRACKING_ACTION, + REQUEST_DELETE_PACKAGE_FILE: REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION, + CANCEL_DELETE_PACKAGE_FILE: CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION, +}; + +export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert'; +export const DELETE_PACKAGE_ERROR_MESSAGE = s__( + 'PackageRegistry|Something went wrong while deleting the package.', +); +export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__( + 'PackageRegistry|Something went wrong while deleting the package file.', +); +export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__( + 'PackageRegistry|Package file deleted successfully', +); + +export const PACKAGE_ERROR_STATUS = 'error'; +export const PACKAGE_DEFAULT_STATUS = 'default'; +export const PACKAGE_HIDDEN_STATUS = 'hidden'; +export const PACKAGE_PROCESSING_STATUS = 'processing'; diff --git a/app/assets/javascripts/packages_and_registries/shared/utils.js b/app/assets/javascripts/packages_and_registries/shared/utils.js index 93eb90535d1..cf18f655e79 100644 --- a/app/assets/javascripts/packages_and_registries/shared/utils.js +++ b/app/assets/javascripts/packages_and_registries/shared/utils.js @@ -28,3 +28,13 @@ export const extractFilterAndSorting = (queryObject) => { } return { filters, sorting }; }; + +export const beautifyPath = (path) => (path ? path.split('/').join(' / ') : ''); + +export const getCommitLink = ({ project_path: projectPath, pipeline = {} }, isGroup = false) => { + if (isGroup) { + return `/${projectPath}/commit/${pipeline.sha}`; + } + + return `../commit/${pipeline.sha}`; +}; diff --git a/app/assets/javascripts/pages/admin/integrations/edit/index.js b/app/assets/javascripts/pages/admin/integrations/edit/index.js index 8002fa8bf78..8485b460261 100644 --- a/app/assets/javascripts/pages/admin/integrations/edit/index.js +++ b/app/assets/javascripts/pages/admin/integrations/edit/index.js @@ -1,15 +1,11 @@ -import IntegrationSettingsForm from '~/integrations/integration_settings_form'; +import initIntegrationSettingsForm from '~/integrations/edit'; import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics'; -function initIntegrations() { - const prometheusSettingsWrapper = document.querySelector('.js-prometheus-metrics-monitoring'); - const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); - integrationSettingsForm.init(); +initIntegrationSettingsForm('.js-integration-settings-form'); - if (prometheusSettingsWrapper) { - const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring'); - prometheusMetrics.loadActiveMetrics(); - } +const prometheusSettingsSelector = '.js-prometheus-metrics-monitoring'; +const prometheusSettingsWrapper = document.querySelector(prometheusSettingsSelector); +if (prometheusSettingsWrapper) { + const prometheusMetrics = new PrometheusMetrics(prometheusSettingsSelector); + prometheusMetrics.loadActiveMetrics(); } - -initIntegrations(); diff --git a/app/assets/javascripts/pages/admin/labels/edit/index.js b/app/assets/javascripts/pages/admin/labels/edit/index.js index f7c25347e75..a3b9c43388a 100644 --- a/app/assets/javascripts/pages/admin/labels/edit/index.js +++ b/app/assets/javascripts/pages/admin/labels/edit/index.js @@ -1,3 +1,3 @@ -import Labels from '../../../../labels'; +import Labels from '~/labels/labels'; new Labels(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/admin/labels/index/index.js b/app/assets/javascripts/pages/admin/labels/index/index.js index 0ceab3b922f..132fe5ce8fc 100644 --- a/app/assets/javascripts/pages/admin/labels/index/index.js +++ b/app/assets/javascripts/pages/admin/labels/index/index.js @@ -1,23 +1,3 @@ -function initLabels() { - const pagination = document.querySelector('.labels .gl-pagination'); - const emptyState = document.querySelector('.labels .nothing-here-block.hidden'); +import { initAdminLabels } from '~/labels'; - function removeLabelSuccessCallback() { - this.closest('li').classList.add('gl-display-none!'); - - const labelsCount = document.querySelectorAll( - 'ul.manage-labels-list li:not(.gl-display-none\\!)', - ).length; - - // display the empty state if there are no more labels - if (labelsCount < 1 && !pagination && emptyState) { - emptyState.classList.remove('hidden'); - } - } - - document.querySelectorAll('.js-remove-label').forEach((row) => { - row.addEventListener('ajax:success', removeLabelSuccessCallback); - }); -} - -initLabels(); +initAdminLabels(); diff --git a/app/assets/javascripts/pages/admin/labels/new/index.js b/app/assets/javascripts/pages/admin/labels/new/index.js index f7c25347e75..a3b9c43388a 100644 --- a/app/assets/javascripts/pages/admin/labels/new/index.js +++ b/app/assets/javascripts/pages/admin/labels/new/index.js @@ -1,3 +1,3 @@ -import Labels from '../../../../labels'; +import Labels from '~/labels/labels'; new Labels(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/admin/services/edit/index.js b/app/assets/javascripts/pages/admin/services/edit/index.js deleted file mode 100644 index b8080ddff77..00000000000 --- a/app/assets/javascripts/pages/admin/services/edit/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import IntegrationSettingsForm from '~/integrations/integration_settings_form'; - -const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); -integrationSettingsForm.init(); diff --git a/app/assets/javascripts/pages/admin/services/index/index.js b/app/assets/javascripts/pages/admin/services/index/index.js deleted file mode 100644 index b695cf70c5d..00000000000 --- a/app/assets/javascripts/pages/admin/services/index/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import PersistentUserCallout from '~/persistent_user_callout'; - -const callout = document.querySelector('.js-service-templates-deprecated'); -PersistentUserCallout.factory(callout); diff --git a/app/assets/javascripts/pages/constants.js b/app/assets/javascripts/pages/constants.js deleted file mode 100644 index a9773807212..00000000000 --- a/app/assets/javascripts/pages/constants.js +++ /dev/null @@ -1,6 +0,0 @@ -export const FILTERED_SEARCH = { - MERGE_REQUESTS: 'merge_requests', - ISSUES: 'issues', - ADMIN_RUNNERS: 'admin/runners', - GROUP_RUNNERS_ANCHOR: 'runners-settings', -}; diff --git a/app/assets/javascripts/pages/dashboard/issues/index.js b/app/assets/javascripts/pages/dashboard/issues/index.js index 3e09b1796b1..d0903ad53bc 100644 --- a/app/assets/javascripts/pages/dashboard/issues/index.js +++ b/app/assets/javascripts/pages/dashboard/issues/index.js @@ -1,6 +1,6 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; -import initManualOrdering from '~/manual_ordering'; -import { FILTERED_SEARCH } from '~/pages/constants'; +import initManualOrdering from '~/issues/manual_ordering'; +import { FILTERED_SEARCH } from '~/filtered_search/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import projectSelect from '~/project_select'; diff --git a/app/assets/javascripts/pages/dashboard/merge_requests/index.js b/app/assets/javascripts/pages/dashboard/merge_requests/index.js index 6c134e4fad6..1350837476b 100644 --- a/app/assets/javascripts/pages/dashboard/merge_requests/index.js +++ b/app/assets/javascripts/pages/dashboard/merge_requests/index.js @@ -1,6 +1,6 @@ import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; -import { FILTERED_SEARCH } from '~/pages/constants'; +import { FILTERED_SEARCH } from '~/filtered_search/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import projectSelect from '~/project_select'; diff --git a/app/assets/javascripts/pages/dashboard/milestones/show/index.js b/app/assets/javascripts/pages/dashboard/milestones/show/index.js index 1f3e458fe17..d1ff7ec336c 100644 --- a/app/assets/javascripts/pages/dashboard/milestones/show/index.js +++ b/app/assets/javascripts/pages/dashboard/milestones/show/index.js @@ -1,4 +1,4 @@ -import Milestone from '~/milestone'; +import Milestone from '~/milestones/milestone'; import Sidebar from '~/right_sidebar'; import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar'; diff --git a/app/assets/javascripts/pages/dashboard/projects/index/components/customize_homepage_banner.vue b/app/assets/javascripts/pages/dashboard/projects/index/components/customize_homepage_banner.vue deleted file mode 100644 index 99461475af0..00000000000 --- a/app/assets/javascripts/pages/dashboard/projects/index/components/customize_homepage_banner.vue +++ /dev/null @@ -1,102 +0,0 @@ -<script> -import { GlBanner } from '@gitlab/ui'; -import axios from '~/lib/utils/axios_utils'; -import { s__ } from '~/locale'; -import Tracking from '~/tracking'; - -const trackingMixin = Tracking.mixin(); - -export default { - components: { - GlBanner, - }, - mixins: [trackingMixin], - inject: { - svgPath: { - default: '', - }, - preferencesBehaviorPath: { - default: '', - }, - calloutsPath: { - default: '', - }, - calloutsFeatureId: { - default: '', - }, - trackLabel: { - default: '', - }, - }, - i18n: { - title: s__('CustomizeHomepageBanner|Do you want to customize this page?'), - body: s__( - 'CustomizeHomepageBanner|This page shows a list of your projects by default but it can be changed to show projects\' activity, groups, your to-do list, assigned issues, assigned merge requests, and more. You can change this under "Homepage content" in your preferences', - ), - button_text: s__('CustomizeHomepageBanner|Go to preferences'), - }, - data() { - return { - visible: true, - tracking: { - label: this.trackLabel, - }, - }; - }, - created() { - this.$nextTick(() => { - this.addTrackingAttributesToButton(); - }); - }, - mounted() { - this.trackOnShow(); - }, - methods: { - handleClose() { - axios - .post(this.calloutsPath, { - feature_name: this.calloutsFeatureId, - }) - .catch((e) => { - // eslint-disable-next-line @gitlab/require-i18n-strings, no-console - console.error('Failed to dismiss banner.', e); - }); - - this.visible = false; - this.track('click_dismiss'); - }, - trackOnShow() { - if (this.visible) this.track('show_home_page_banner'); - }, - addTrackingAttributesToButton() { - // we can't directly add these on the button like we need to due to - // button not being modifiable currently - // https://gitlab.com/gitlab-org/gitlab-ui/-/blob/9209ec424e5cca14bc8a1b5c9fa12636d8c83dad/src/components/base/banner/banner.vue#L60 - const button = this.$refs.banner.$el.querySelector( - `[href='${this.preferencesBehaviorPath}']`, - ); - - if (button) { - button.setAttribute('data-track-action', 'click_go_to_preferences'); - button.setAttribute('data-track-label', this.trackLabel); - } - }, - }, -}; -</script> - -<template> - <gl-banner - v-if="visible" - ref="banner" - :title="$options.i18n.title" - :button-text="$options.i18n.button_text" - :button-link="preferencesBehaviorPath" - :svg-path="svgPath" - @close="handleClose" - > - <p> - {{ $options.i18n.body }} - </p> - </gl-banner> -</template> diff --git a/app/assets/javascripts/pages/dashboard/projects/index/index.js b/app/assets/javascripts/pages/dashboard/projects/index/index.js index c34d15b869a..6c9378b7231 100644 --- a/app/assets/javascripts/pages/dashboard/projects/index/index.js +++ b/app/assets/javascripts/pages/dashboard/projects/index/index.js @@ -1,5 +1,3 @@ import ProjectsList from '~/projects_list'; -import initCustomizeHomepageBanner from './init_customize_homepage_banner'; new ProjectsList(); // eslint-disable-line no-new -initCustomizeHomepageBanner(); diff --git a/app/assets/javascripts/pages/dashboard/projects/index/init_customize_homepage_banner.js b/app/assets/javascripts/pages/dashboard/projects/index/init_customize_homepage_banner.js deleted file mode 100644 index 8cdcd3134ee..00000000000 --- a/app/assets/javascripts/pages/dashboard/projects/index/init_customize_homepage_banner.js +++ /dev/null @@ -1,16 +0,0 @@ -import Vue from 'vue'; -import CustomizeHomepageBanner from './components/customize_homepage_banner.vue'; - -export default () => { - const el = document.querySelector('.js-customize-homepage-banner'); - - if (!el) { - return false; - } - - return new Vue({ - el, - provide: { ...el.dataset }, - render: (createElement) => createElement(CustomizeHomepageBanner), - }); -}; diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index 8c9f23732aa..966d55e5587 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -1,8 +1,8 @@ import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; -import issuableInitBulkUpdateSidebar from '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar'; +import issuableInitBulkUpdateSidebar from '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar'; import { mountIssuablesListApp, mountIssuesListApp } from '~/issues_list'; -import initManualOrdering from '~/manual_ordering'; -import { FILTERED_SEARCH } from '~/pages/constants'; +import initManualOrdering from '~/issues/manual_ordering'; +import { FILTERED_SEARCH } from '~/filtered_search/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import projectSelect from '~/project_select'; diff --git a/app/assets/javascripts/pages/groups/labels/edit/index.js b/app/assets/javascripts/pages/groups/labels/edit/index.js index 2e8308fe084..e4e377f62fc 100644 --- a/app/assets/javascripts/pages/groups/labels/edit/index.js +++ b/app/assets/javascripts/pages/groups/labels/edit/index.js @@ -1,4 +1,4 @@ -import Labels from 'ee_else_ce/labels'; +import Labels from 'ee_else_ce/labels/labels'; // eslint-disable-next-line no-new new Labels(); diff --git a/app/assets/javascripts/pages/groups/labels/index/index.js b/app/assets/javascripts/pages/groups/labels/index/index.js index 95c2c7cd7d0..bf670e8576f 100644 --- a/app/assets/javascripts/pages/groups/labels/index/index.js +++ b/app/assets/javascripts/pages/groups/labels/index/index.js @@ -1,5 +1,4 @@ -import initDeleteLabelModal from '~/delete_label_modal'; -import initLabels from '~/init_labels'; +import { initDeleteLabelModal, initLabels } from '~/labels'; initLabels(); initDeleteLabelModal(); diff --git a/app/assets/javascripts/pages/groups/labels/new/index.js b/app/assets/javascripts/pages/groups/labels/new/index.js index 2e8308fe084..e4e377f62fc 100644 --- a/app/assets/javascripts/pages/groups/labels/new/index.js +++ b/app/assets/javascripts/pages/groups/labels/new/index.js @@ -1,4 +1,4 @@ -import Labels from 'ee_else_ce/labels'; +import Labels from 'ee_else_ce/labels/labels'; // eslint-disable-next-line no-new new Labels(); diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js index 02a0a50f984..cb38ee1c6e0 100644 --- a/app/assets/javascripts/pages/groups/merge_requests/index.js +++ b/app/assets/javascripts/pages/groups/merge_requests/index.js @@ -1,7 +1,7 @@ import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; -import issuableInitBulkUpdateSidebar from '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar'; -import { FILTERED_SEARCH } from '~/pages/constants'; +import issuableInitBulkUpdateSidebar from '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar'; +import { FILTERED_SEARCH } from '~/filtered_search/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import projectSelect from '~/project_select'; diff --git a/app/assets/javascripts/pages/groups/milestones/edit/index.js b/app/assets/javascripts/pages/groups/milestones/edit/index.js index 4f8514a9a1d..7fda129a85d 100644 --- a/app/assets/javascripts/pages/groups/milestones/edit/index.js +++ b/app/assets/javascripts/pages/groups/milestones/edit/index.js @@ -1,3 +1,3 @@ -import initForm from '~/shared/milestones/form'; +import { initForm } from '~/milestones'; initForm(); diff --git a/app/assets/javascripts/pages/groups/milestones/new/index.js b/app/assets/javascripts/pages/groups/milestones/new/index.js index 4f8514a9a1d..7fda129a85d 100644 --- a/app/assets/javascripts/pages/groups/milestones/new/index.js +++ b/app/assets/javascripts/pages/groups/milestones/new/index.js @@ -1,3 +1,3 @@ -import initForm from '~/shared/milestones/form'; +import { initForm } from '~/milestones'; initForm(); diff --git a/app/assets/javascripts/pages/groups/milestones/show/index.js b/app/assets/javascripts/pages/groups/milestones/show/index.js index 914e2831185..f2ab5d78374 100644 --- a/app/assets/javascripts/pages/groups/milestones/show/index.js +++ b/app/assets/javascripts/pages/groups/milestones/show/index.js @@ -1,5 +1,4 @@ -import initDeleteMilestoneModal from '~/pages/milestones/shared/delete_milestone_modal_init'; -import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show'; +import { initDeleteMilestoneModal, initShow } from '~/milestones'; -initMilestonesShow(); +initShow(); initDeleteMilestoneModal(); diff --git a/app/assets/javascripts/pages/groups/packages/index/index.js b/app/assets/javascripts/pages/groups/packages/index/index.js index f9eecff4ac4..174973a9fad 100644 --- a/app/assets/javascripts/pages/groups/packages/index/index.js +++ b/app/assets/javascripts/pages/groups/packages/index/index.js @@ -1,3 +1,3 @@ -import packageList from '~/packages_and_registries/package_registry/pages/list'; +import packageApp from '~/packages_and_registries/package_registry/index'; -packageList(); +packageApp(); diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js index a8d7a83cdd6..5d8ee146e62 100644 --- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js @@ -1,7 +1,7 @@ import initVariableList from '~/ci_variable_list'; import GroupRunnersFilteredSearchTokenKeys from '~/filtered_search/group_runners_filtered_search_token_keys'; import initSharedRunnersForm from '~/group_settings/mount_shared_runners'; -import { FILTERED_SEARCH } from '~/pages/constants'; +import { FILTERED_SEARCH } from '~/filtered_search/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import { initRunnerAwsDeployments } from '~/pages/shared/mount_runner_aws_deployments'; import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; diff --git a/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js b/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js index a8698e10c57..8485b460261 100644 --- a/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js +++ b/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js @@ -1,11 +1,11 @@ -import IntegrationSettingsForm from '~/integrations/integration_settings_form'; +import initIntegrationSettingsForm from '~/integrations/edit'; import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics'; -const prometheusSettingsWrapper = document.querySelector('.js-prometheus-metrics-monitoring'); -const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); -integrationSettingsForm.init(); +initIntegrationSettingsForm('.js-integration-settings-form'); +const prometheusSettingsSelector = '.js-prometheus-metrics-monitoring'; +const prometheusSettingsWrapper = document.querySelector(prometheusSettingsSelector); if (prometheusSettingsWrapper) { - const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring'); + const prometheusMetrics = new PrometheusMetrics(prometheusSettingsSelector); prometheusMetrics.loadActiveMetrics(); } diff --git a/app/assets/javascripts/pages/help/ui/index.js b/app/assets/javascripts/pages/help/ui/index.js deleted file mode 100644 index 9ccc9123506..00000000000 --- a/app/assets/javascripts/pages/help/ui/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import initUIKit from '~/ui_development_kit'; - -initUIKit(); diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue index ec3cf4a8a92..0ec382983a5 100644 --- a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue +++ b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue @@ -7,7 +7,7 @@ import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { joinPaths } from '~/lib/utils/url_utility'; import { getBulkImportsHistory } from '~/rest_api'; import ImportStatus from '~/import_entities/components/import_status.vue'; -import PaginationBar from '~/import_entities/components/pagination_bar.vue'; +import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { DEFAULT_ERROR } from '../utils/error_messages'; @@ -166,7 +166,6 @@ export default { </gl-table> <pagination-bar :page-info="pageInfo" - :items-count="historyItems.length" class="gl-m-0 gl-mt-3" @set-page="paginationConfig.page = $event" @set-page-size="paginationConfig.perPage = $event" diff --git a/app/assets/javascripts/pages/milestones/shared/index.js b/app/assets/javascripts/pages/milestones/shared/index.js deleted file mode 100644 index dabfe32848b..00000000000 --- a/app/assets/javascripts/pages/milestones/shared/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import initDeleteMilestoneModal from './delete_milestone_modal_init'; -import initPromoteMilestoneModal from './promote_milestone_modal_init'; - -export default () => { - initDeleteMilestoneModal(); - initPromoteMilestoneModal(); -}; diff --git a/app/assets/javascripts/pages/milestones/shared/init_milestones_show.js b/app/assets/javascripts/pages/milestones/shared/init_milestones_show.js deleted file mode 100644 index b2a896a3265..00000000000 --- a/app/assets/javascripts/pages/milestones/shared/init_milestones_show.js +++ /dev/null @@ -1,11 +0,0 @@ -/* eslint-disable no-new */ - -import Milestone from '~/milestone'; -import Sidebar from '~/right_sidebar'; -import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar'; - -export default () => { - new Milestone(); - new Sidebar(); - new MountMilestoneSidebar(); -}; diff --git a/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js b/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js deleted file mode 100644 index 5472b8c684f..00000000000 --- a/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js +++ /dev/null @@ -1,19 +0,0 @@ -import Vue from 'vue'; -import Translate from '~/vue_shared/translate'; -import PromoteMilestoneModal from './components/promote_milestone_modal.vue'; - -Vue.use(Translate); - -export default () => { - const promoteMilestoneModal = document.getElementById('promote-milestone-modal'); - if (!promoteMilestoneModal) { - return null; - } - - return new Vue({ - el: promoteMilestoneModal, - render(createElement) { - return createElement(PromoteMilestoneModal); - }, - }); -}; diff --git a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js index fdbfc35456f..37e9b7e99d4 100644 --- a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js +++ b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js @@ -1,4 +1,5 @@ -import { initExpiresAtField, initProjectsField } from '~/access_tokens'; +import { initExpiresAtField, initProjectsField, initTokensApp } from '~/access_tokens'; initExpiresAtField(); initProjectsField(); +initTokensApp(); diff --git a/app/assets/javascripts/pages/projects/constants.js b/app/assets/javascripts/pages/projects/constants.js deleted file mode 100644 index 8dc765e5d10..00000000000 --- a/app/assets/javascripts/pages/projects/constants.js +++ /dev/null @@ -1,4 +0,0 @@ -export const ISSUABLE_INDEX = { - MERGE_REQUEST: 'merge_request_', - ISSUE: 'issue_', -}; diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js index f4beefea90c..100ca5b36d9 100644 --- a/app/assets/javascripts/pages/projects/edit/index.js +++ b/app/assets/javascripts/pages/projects/edit/index.js @@ -1,13 +1,14 @@ import { PROJECT_BADGE } from '~/badges/constants'; import initLegacyConfirmDangerModal from '~/confirm_danger_modal'; +import initConfirmDanger from '~/init_confirm_danger'; import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory'; import initFilePickers from '~/file_pickers'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; import initProjectDeleteButton from '~/projects/project_delete_button'; import initServiceDesk from '~/projects/settings_service_desk'; +import initTransferProjectForm from '~/projects/settings/init_transfer_project_form'; import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; -import setupTransferEdit from '~/transfer_edit'; import UserCallout from '~/user_callout'; import initTopicsTokenSelector from '~/projects/settings/topics'; import initProjectPermissionsSettings from '../shared/permissions'; @@ -15,6 +16,7 @@ import initProjectLoadingSpinner from '../shared/save_project_loader'; initFilePickers(); initLegacyConfirmDangerModal(); +initConfirmDanger(); initSettingsPanels(); initProjectDeleteButton(); mountBadgeSettings(PROJECT_BADGE); @@ -24,7 +26,7 @@ initServiceDesk(); initProjectLoadingSpinner(); initProjectPermissionsSettings(); -setupTransferEdit('.js-project-transfer-form', 'select.select2'); +initTransferProjectForm(); dirtySubmitFactory(document.querySelectorAll('.js-general-settings-form, .js-mr-settings-form')); diff --git a/app/assets/javascripts/pages/projects/incidents/show/index.js b/app/assets/javascripts/pages/projects/incidents/show/index.js index a75b68873ef..4633eaef8f9 100644 --- a/app/assets/javascripts/pages/projects/incidents/show/index.js +++ b/app/assets/javascripts/pages/projects/incidents/show/index.js @@ -1,6 +1,6 @@ import initRelatedIssues from '~/related_issues'; import initSidebarBundle from '~/sidebar/sidebar_bundle'; -import initShow from '../../issues/show'; +import initShow from '~/issues/show'; initShow(); initSidebarBundle(); diff --git a/app/assets/javascripts/pages/projects/issues/edit/index.js b/app/assets/javascripts/pages/projects/issues/edit/index.js index 48afd2142ee..aa00d1f58bd 100644 --- a/app/assets/javascripts/pages/projects/issues/edit/index.js +++ b/app/assets/javascripts/pages/projects/issues/edit/index.js @@ -1,3 +1,3 @@ -import initForm from 'ee_else_ce/pages/projects/issues/form'; +import initForm from 'ee_else_ce/issues/form'; initForm(); diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js index 8cd703133f5..e937713044c 100644 --- a/app/assets/javascripts/pages/projects/issues/index/index.js +++ b/app/assets/javascripts/pages/projects/issues/index/index.js @@ -1,12 +1,11 @@ import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; -import initCsvImportExportButtons from '~/issuable/init_csv_import_export_buttons'; -import initIssuableByEmail from '~/issuable/init_issuable_by_email'; -import IssuableIndex from '~/issuable_index'; +import { initCsvImportExportButtons, initIssuableByEmail } from '~/issuable'; +import issuableInitBulkUpdateSidebar from '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar'; import { mountIssuablesListApp, mountIssuesListApp, mountJiraIssuesListApp } from '~/issues_list'; -import initManualOrdering from '~/manual_ordering'; -import { FILTERED_SEARCH } from '~/pages/constants'; -import { ISSUABLE_INDEX } from '~/pages/projects/constants'; +import initManualOrdering from '~/issues/manual_ordering'; +import { FILTERED_SEARCH } from '~/filtered_search/constants'; +import { ISSUABLE_INDEX } from '~/issuable/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import UsersSelect from '~/users_select'; @@ -21,7 +20,7 @@ if (gon.features?.vueIssuesList) { useDefaultState: true, }); - new IssuableIndex(ISSUABLE_INDEX.ISSUE); // eslint-disable-line no-new + issuableInitBulkUpdateSidebar.init(ISSUABLE_INDEX.ISSUE); new UsersSelect(); // eslint-disable-line no-new initCsvImportExportButtons(); diff --git a/app/assets/javascripts/pages/projects/issues/new/index.js b/app/assets/javascripts/pages/projects/issues/new/index.js index 48afd2142ee..aa00d1f58bd 100644 --- a/app/assets/javascripts/pages/projects/issues/new/index.js +++ b/app/assets/javascripts/pages/projects/issues/new/index.js @@ -1,3 +1,3 @@ -import initForm from 'ee_else_ce/pages/projects/issues/form'; +import initForm from 'ee_else_ce/issues/form'; initForm(); diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/index.js b/app/assets/javascripts/pages/projects/issues/service_desk/index.js index d906c579697..69639d17f8a 100644 --- a/app/assets/javascripts/pages/projects/issues/service_desk/index.js +++ b/app/assets/javascripts/pages/projects/issues/service_desk/index.js @@ -1,14 +1,7 @@ import { mountIssuablesListApp } from '~/issues_list'; -import FilteredSearchServiceDesk from './filtered_search'; +import { initFilteredSearchServiceDesk } from '~/issues/init_filtered_search_service_desk'; -const supportBotData = JSON.parse( - document.querySelector('.js-service-desk-issues').dataset.supportBot, -); - -if (document.querySelector('.filtered-search')) { - const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData); - filteredSearchManager.setup(); -} +initFilteredSearchServiceDesk(); if (gon.features?.vueIssuablesList) { mountIssuablesListApp(); diff --git a/app/assets/javascripts/pages/projects/issues/show/index.js b/app/assets/javascripts/pages/projects/issues/show/index.js index 1282d2aa303..d0b1942f2a4 100644 --- a/app/assets/javascripts/pages/projects/issues/show/index.js +++ b/app/assets/javascripts/pages/projects/issues/show/index.js @@ -1,7 +1,7 @@ import { store } from '~/notes/stores'; import initRelatedIssues from '~/related_issues'; import initSidebarBundle from '~/sidebar/sidebar_bundle'; -import initShow from '../show'; +import initShow from '~/issues/show'; initShow(); initSidebarBundle(store); diff --git a/app/assets/javascripts/pages/projects/labels/edit/index.js b/app/assets/javascripts/pages/projects/labels/edit/index.js index 3b7562deed9..c4d7af39767 100644 --- a/app/assets/javascripts/pages/projects/labels/edit/index.js +++ b/app/assets/javascripts/pages/projects/labels/edit/index.js @@ -1,3 +1,3 @@ -import Labels from 'ee_else_ce/labels'; +import Labels from 'ee_else_ce/labels/labels'; new Labels(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/labels/index/index.js b/app/assets/javascripts/pages/projects/labels/index/index.js index 94ab0d64de4..1f8ff7e0bb1 100644 --- a/app/assets/javascripts/pages/projects/labels/index/index.js +++ b/app/assets/javascripts/pages/projects/labels/index/index.js @@ -1,83 +1,3 @@ -import Vue from 'vue'; -import initDeleteLabelModal from '~/delete_label_modal'; -import initLabels from '~/init_labels'; -import { BV_SHOW_MODAL } from '~/lib/utils/constants'; -import Translate from '~/vue_shared/translate'; -import PromoteLabelModal from '../components/promote_label_modal.vue'; -import eventHub from '../event_hub'; +import { initLabelIndex } from '~/labels'; -Vue.use(Translate); - -const initLabelIndex = () => { - initLabels(); - initDeleteLabelModal(); - - const onRequestFinished = ({ labelUrl, successful }) => { - const button = document.querySelector( - `.js-promote-project-label-button[data-url="${labelUrl}"]`, - ); - - if (!successful) { - button.removeAttribute('disabled'); - } - }; - - const onRequestStarted = (labelUrl) => { - const button = document.querySelector( - `.js-promote-project-label-button[data-url="${labelUrl}"]`, - ); - button.setAttribute('disabled', ''); - eventHub.$once('promoteLabelModal.requestFinished', onRequestFinished); - }; - - const promoteLabelButtons = document.querySelectorAll('.js-promote-project-label-button'); - - return new Vue({ - el: '#js-promote-label-modal', - data() { - return { - modalProps: { - labelTitle: '', - labelColor: '', - labelTextColor: '', - url: '', - groupName: '', - }, - }; - }, - mounted() { - eventHub.$on('promoteLabelModal.props', this.setModalProps); - eventHub.$emit('promoteLabelModal.mounted'); - - promoteLabelButtons.forEach((button) => { - button.removeAttribute('disabled'); - button.addEventListener('click', () => { - this.$root.$emit(BV_SHOW_MODAL, 'promote-label-modal'); - eventHub.$once('promoteLabelModal.requestStarted', onRequestStarted); - - this.setModalProps({ - labelTitle: button.dataset.labelTitle, - labelColor: button.dataset.labelColor, - labelTextColor: button.dataset.labelTextColor, - url: button.dataset.url, - groupName: button.dataset.groupName, - }); - }); - }); - }, - beforeDestroy() { - eventHub.$off('promoteLabelModal.props', this.setModalProps); - }, - methods: { - setModalProps(modalProps) { - this.modalProps = modalProps; - }, - }, - render(createElement) { - return createElement(PromoteLabelModal, { - props: this.modalProps, - }); - }, - }); -}; initLabelIndex(); diff --git a/app/assets/javascripts/pages/projects/labels/new/index.js b/app/assets/javascripts/pages/projects/labels/new/index.js index 2e8308fe084..e4e377f62fc 100644 --- a/app/assets/javascripts/pages/projects/labels/new/index.js +++ b/app/assets/javascripts/pages/projects/labels/new/index.js @@ -1,4 +1,4 @@ -import Labels from 'ee_else_ce/labels'; +import Labels from 'ee_else_ce/labels/labels'; // eslint-disable-next-line no-new new Labels(); diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue index 95afcb6bda8..42c40cda601 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue @@ -1,18 +1,21 @@ <script> -import { GlProgressBar, GlSprintf } from '@gitlab/ui'; +import { GlProgressBar, GlSprintf, GlAlert } from '@gitlab/ui'; import eventHub from '~/invite_members/event_hub'; import { s__ } from '~/locale'; import { ACTION_LABELS, ACTION_SECTIONS } from '../constants'; import LearnGitlabSectionCard from './learn_gitlab_section_card.vue'; export default { - components: { GlProgressBar, GlSprintf, LearnGitlabSectionCard }, + components: { GlProgressBar, GlSprintf, GlAlert, LearnGitlabSectionCard }, i18n: { title: s__('LearnGitLab|Learn GitLab'), description: s__( 'LearnGitLab|Ready to get started with GitLab? Follow these steps to set up your workspace, plan and commit changes, and deploy your project.', ), percentageCompleted: s__(`LearnGitLab|%{percentage}%{percentSymbol} completed`), + successfulInvitations: s__( + "LearnGitLab|Your team is growing! You've successfully invited new team members to the %{projectName} project.", + ), }, props: { actions: { @@ -28,12 +31,22 @@ export default { required: false, default: false, }, + project: { + required: true, + type: Object, + }, + }, + data() { + return { + showSuccessfulInvitationsAlert: false, + actionsData: this.actions, + }; }, maxValue: Object.keys(ACTION_LABELS).length, actionSections: Object.keys(ACTION_SECTIONS), computed: { progressValue() { - return Object.values(this.actions).filter((a) => a.completed).length; + return Object.values(this.actionsData).filter((a) => a.completed).length; }, progressPercentage() { return Math.round((this.progressValue / this.$options.maxValue) * 100); @@ -43,14 +56,23 @@ export default { if (this.inviteMembersOpen) { this.openInviteMembersModal('celebrate'); } + + eventHub.$on('showSuccessfulInvitationsAlert', this.handleShowSuccessfulInvitationsAlert); + }, + beforeDestroy() { + eventHub.$off('showSuccessfulInvitationsAlert', this.handleShowSuccessfulInvitationsAlert); }, methods: { openInviteMembersModal(mode) { eventHub.$emit('openModal', { mode, inviteeType: 'members', source: 'learn-gitlab' }); }, + handleShowSuccessfulInvitationsAlert() { + this.showSuccessfulInvitationsAlert = true; + this.markActionAsCompleted('userAdded'); + }, actionsFor(section) { const actions = Object.fromEntries( - Object.entries(this.actions).filter( + Object.entries(this.actionsData).filter( ([action]) => ACTION_LABELS[action].section === section, ), ); @@ -59,11 +81,34 @@ export default { svgFor(section) { return this.sections[section].svg; }, + markActionAsCompleted(completedAction) { + Object.keys(this.actionsData).forEach((action) => { + if (action === completedAction) { + this.actionsData[action].completed = true; + this.modifySidebarPercentage(); + } + }); + }, + modifySidebarPercentage() { + const el = document.querySelector('.sidebar-top-level-items .active .count'); + el.textContent = `${this.progressPercentage}%`; + }, }, }; </script> <template> <div> + <gl-alert + v-if="showSuccessfulInvitationsAlert" + class="gl-mt-5" + @dismiss="showSuccessfulInvitationsAlert = false" + > + <gl-sprintf :message="$options.i18n.successfulInvitations"> + <template #projectName> + <strong>{{ project.name }}</strong> + </template> + </gl-sprintf> + </gl-alert> <div class="row"> <div class="gl-mb-7 gl-ml-5"> <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue index 0995947f3e7..3a401f5cb31 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue @@ -1,5 +1,7 @@ <script> import { GlLink, GlIcon } from '@gitlab/ui'; +import { isExperimentVariant } from '~/experimentation/utils'; +import eventHub from '~/invite_members/event_hub'; import { s__ } from '~/locale'; import { ACTION_LABELS } from '../constants'; @@ -24,6 +26,20 @@ export default { trialOnly() { return ACTION_LABELS[this.action].trialRequired; }, + showInviteModalLink() { + return ( + this.action === 'userAdded' && isExperimentVariant('invite_for_help_continuous_onboarding') + ); + }, + }, + methods: { + openModal() { + eventHub.$emit('openModal', { + inviteeType: 'members', + source: 'learn_gitlab', + tasksToBeDoneEnabled: true, + }); + }, }, }; </script> @@ -33,18 +49,27 @@ export default { <gl-icon name="check-circle-filled" :size="16" data-testid="completed-icon" /> {{ $options.i18n.ACTION_LABELS[action].title }} </span> - <span v-else> - <gl-link - target="_blank" - :href="value.url" - data-track-action="click_link" - :data-track-label="$options.i18n.ACTION_LABELS[action].title" - data-track-property="Growth::Conversion::Experiment::LearnGitLab" - data-track-experiment="change_continuous_onboarding_link_urls" - > - {{ $options.i18n.ACTION_LABELS[action].title }} - </gl-link> - </span> + <gl-link + v-else-if="showInviteModalLink" + data-track-action="click_link" + :data-track-label="$options.i18n.ACTION_LABELS[action].title" + data-track-property="Growth::Activation::Experiment::InviteForHelpContinuousOnboarding" + data-testid="invite-for-help-continuous-onboarding-experiment-link" + @click="openModal" + > + {{ $options.i18n.ACTION_LABELS[action].title }} + </gl-link> + <gl-link + v-else + target="_blank" + :href="value.url" + data-track-action="click_link" + :data-track-label="$options.i18n.ACTION_LABELS[action].title" + data-track-property="Growth::Conversion::Experiment::LearnGitLab" + data-track-experiment="change_continuous_onboarding_link_urls" + > + {{ $options.i18n.ACTION_LABELS[action].title }} + </gl-link> <span v-if="trialOnly" class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only"> - {{ $options.i18n.trialOnly }} </span> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js index ea9eec2595f..1f91cc46946 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js +++ b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js @@ -12,17 +12,18 @@ function initLearnGitlab() { const actions = convertObjectPropsToCamelCase(JSON.parse(el.dataset.actions)); const sections = convertObjectPropsToCamelCase(JSON.parse(el.dataset.sections)); + const project = convertObjectPropsToCamelCase(JSON.parse(el.dataset.project)); const { inviteMembersOpen } = el.dataset; return new Vue({ el, render(createElement) { return createElement(LearnGitlab, { - props: { actions, sections, inviteMembersOpen }, + props: { actions, sections, project, inviteMembersOpen }, }); }, }); } -initInviteMembersModal(); initLearnGitlab(); +initInviteMembersModal(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js index d279086df7b..acd1731a700 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js @@ -1,17 +1,17 @@ import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; -import initCsvImportExportButtons from '~/issuable/init_csv_import_export_buttons'; -import initIssuableByEmail from '~/issuable/init_issuable_by_email'; -import IssuableIndex from '~/issuable_index'; -import { FILTERED_SEARCH } from '~/pages/constants'; -import { ISSUABLE_INDEX } from '~/pages/projects/constants'; +import { initCsvImportExportButtons, initIssuableByEmail } from '~/issuable'; +import issuableInitBulkUpdateSidebar from '~/issuable/bulk_update_sidebar/issuable_init_bulk_update_sidebar'; +import { FILTERED_SEARCH } from '~/filtered_search/constants'; +import { ISSUABLE_INDEX } from '~/issuable/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import UsersSelect from '~/users_select'; -new IssuableIndex(ISSUABLE_INDEX.MERGE_REQUEST); // eslint-disable-line no-new +issuableInitBulkUpdateSidebar.init(ISSUABLE_INDEX.MERGE_REQUEST); addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); +IssuableFilteredSearchTokenKeys.removeTokensForKeys('iteration'); initFilteredSearch({ page: FILTERED_SEARCH.MERGE_REQUESTS, diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js index 7d5719cf8a8..ebf7c266482 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js @@ -1,13 +1,13 @@ /* eslint-disable no-new */ import $ from 'jquery'; -import IssuableForm from 'ee_else_ce/issuable_form'; +import IssuableForm from 'ee_else_ce/issuable/issuable_form'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import Diff from '~/diff'; import GLForm from '~/gl_form'; -import LabelsSelect from '~/labels_select'; -import MilestoneSelect from '~/milestone_select'; -import IssuableTemplateSelectors from '~/templates/issuable_template_selectors'; +import LabelsSelect from '~/labels/labels_select'; +import MilestoneSelect from '~/milestones/milestone_select'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; export default () => { new Diff(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js index 99094617b0a..c548ea9bb80 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js @@ -3,7 +3,7 @@ import VueApollo from 'vue-apollo'; import loadAwardsHandler from '~/awards_handler'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import { initPipelineCountListener } from '~/commit/pipelines/utils'; -import initIssuableSidebar from '~/init_issuable_sidebar'; +import { initIssuableSidebar } from '~/issuable'; import StatusBox from '~/issuable/components/status_box.vue'; import createDefaultClient from '~/lib/graphql'; import initSourcegraph from '~/sourcegraph'; diff --git a/app/assets/javascripts/pages/projects/merge_requests/queries/get_state.query.graphql b/app/assets/javascripts/pages/projects/merge_requests/queries/get_state.query.graphql index b5a82b9428e..1edb37a228d 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/queries/get_state.query.graphql +++ b/app/assets/javascripts/pages/projects/merge_requests/queries/get_state.query.graphql @@ -1,6 +1,8 @@ query getMergeRequestState($projectPath: ID!, $iid: String!) { workspace: project(fullPath: $projectPath) { + id issuable: mergeRequest(iid: $iid) { + id state } } diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js index 25dede33880..7f49eb60c5c 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js @@ -1,8 +1,8 @@ import { initReviewBar } from '~/batch_comments'; +import { initIssuableHeaderWarnings } from '~/issuable'; import initMrNotes from '~/mr_notes'; import store from '~/mr_notes/stores'; import initSidebarBundle from '~/sidebar/sidebar_bundle'; -import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning'; import initShow from '../init_merge_request_show'; initMrNotes(); @@ -11,5 +11,5 @@ initShow(); requestIdleCallback(() => { initSidebarBundle(store); initReviewBar(); - initIssuableHeaderWarning(store); + initIssuableHeaderWarnings(store); }); diff --git a/app/assets/javascripts/pages/projects/milestones/edit/index.js b/app/assets/javascripts/pages/projects/milestones/edit/index.js index 4f8514a9a1d..7fda129a85d 100644 --- a/app/assets/javascripts/pages/projects/milestones/edit/index.js +++ b/app/assets/javascripts/pages/projects/milestones/edit/index.js @@ -1,3 +1,3 @@ -import initForm from '~/shared/milestones/form'; +import { initForm } from '~/milestones'; initForm(); diff --git a/app/assets/javascripts/pages/projects/milestones/index/index.js b/app/assets/javascripts/pages/projects/milestones/index/index.js index 150b506b121..ef1c9ab83db 100644 --- a/app/assets/javascripts/pages/projects/milestones/index/index.js +++ b/app/assets/javascripts/pages/projects/milestones/index/index.js @@ -1,3 +1,4 @@ -import milestones from '~/pages/milestones/shared'; +import { initDeleteMilestoneModal, initPromoteMilestoneModal } from '~/milestones'; -milestones(); +initDeleteMilestoneModal(); +initPromoteMilestoneModal(); diff --git a/app/assets/javascripts/pages/projects/milestones/new/index.js b/app/assets/javascripts/pages/projects/milestones/new/index.js index 4f8514a9a1d..7fda129a85d 100644 --- a/app/assets/javascripts/pages/projects/milestones/new/index.js +++ b/app/assets/javascripts/pages/projects/milestones/new/index.js @@ -1,3 +1,3 @@ -import initForm from '~/shared/milestones/form'; +import { initForm } from '~/milestones'; initForm(); diff --git a/app/assets/javascripts/pages/projects/milestones/show/index.js b/app/assets/javascripts/pages/projects/milestones/show/index.js index 3c755e9b98c..16aac7748da 100644 --- a/app/assets/javascripts/pages/projects/milestones/show/index.js +++ b/app/assets/javascripts/pages/projects/milestones/show/index.js @@ -1,5 +1,5 @@ -import milestones from '~/pages/milestones/shared'; -import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show'; +import { initDeleteMilestoneModal, initPromoteMilestoneModal, initShow } from '~/milestones'; -initMilestonesShow(); -milestones(); +initShow(); +initDeleteMilestoneModal(); +initPromoteMilestoneModal(); diff --git a/app/assets/javascripts/pages/projects/packages/packages/index/index.js b/app/assets/javascripts/pages/projects/packages/packages/index/index.js index f9eecff4ac4..174973a9fad 100644 --- a/app/assets/javascripts/pages/projects/packages/packages/index/index.js +++ b/app/assets/javascripts/pages/projects/packages/packages/index/index.js @@ -1,3 +1,3 @@ -import packageList from '~/packages_and_registries/package_registry/pages/list'; +import packageApp from '~/packages_and_registries/package_registry/index'; -packageList(); +packageApp(); diff --git a/app/assets/javascripts/pages/projects/path_locks/index.js b/app/assets/javascripts/pages/projects/path_locks/index.js deleted file mode 100644 index e5ab5d43bbf..00000000000 --- a/app/assets/javascripts/pages/projects/path_locks/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior'; - -document.addEventListener('DOMContentLoaded', initDeprecatedRemoveRowBehavior); diff --git a/app/assets/javascripts/pages/projects/services/edit/index.js b/app/assets/javascripts/pages/projects/services/edit/index.js index 03ffc323fc0..a2b18d86240 100644 --- a/app/assets/javascripts/pages/projects/services/edit/index.js +++ b/app/assets/javascripts/pages/projects/services/edit/index.js @@ -1,9 +1,8 @@ -import IntegrationSettingsForm from '~/integrations/integration_settings_form'; +import initIntegrationSettingsForm from '~/integrations/edit'; import PrometheusAlerts from '~/prometheus_alerts'; import CustomMetrics from '~/prometheus_metrics/custom_metrics'; -const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); -integrationSettingsForm.init(); +initIntegrationSettingsForm('.js-integration-settings-form'); const prometheusSettingsSelector = '.js-prometheus-metrics-monitoring'; const prometheusSettingsWrapper = document.querySelector(prometheusSettingsSelector); diff --git a/app/assets/javascripts/pages/projects/usage_quotas/index.js b/app/assets/javascripts/pages/projects/usage_quotas/index.js deleted file mode 100644 index 9cd80b85c8a..00000000000 --- a/app/assets/javascripts/pages/projects/usage_quotas/index.js +++ /dev/null @@ -1,23 +0,0 @@ -import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs'; -import storageCounter from '~/projects/storage_counter'; -import initSearchSettings from '~/search_settings'; - -const initLinkedTabs = () => { - if (!document.querySelector('.js-usage-quota-tabs')) { - return false; - } - - return new LinkedTabs({ - defaultAction: '#storage-quota-tab', - parentEl: '.js-usage-quota-tabs', - hashedTabs: true, - }); -}; - -const initVueApp = () => { - storageCounter('js-project-storage-count-app'); -}; - -initVueApp(); -initLinkedTabs(); -initSearchSettings(); diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue index 6f19a9f4379..b29e9455755 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -15,6 +15,7 @@ import { setUrlFragment } from '~/lib/utils/url_utility'; import { __, s__, sprintf } from '~/locale'; import Tracking from '~/tracking'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { CONTENT_EDITOR_LOADED_ACTION, SAVED_USING_CONTENT_EDITOR_ACTION, @@ -46,7 +47,7 @@ export default { newPage: s__( 'WikiPage|Tip: You can specify the full path for the new file. We will automatically create any missing directories.', ), - moreInformation: s__('WikiPage|More Information.'), + learnMore: s__('WikiPage|Learn more.'), }, }, format: { @@ -104,6 +105,8 @@ export default { newPage: s__('WikiPage|Create page'), }, cancel: s__('WikiPage|Cancel'), + editSourceButtonText: s__('WikiPage|Edit source'), + editRichTextButtonText: s__('WikiPage|Edit rich text'), }, contentEditorFeedbackIssue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/332629', components: { @@ -123,7 +126,7 @@ export default { directives: { GlModalDirective, }, - mixins: [trackingMixin], + mixins: [trackingMixin, glFeatureFlagMixin()], inject: ['formatOptions', 'pageInfo'], data() { return { @@ -131,7 +134,6 @@ export default { format: this.pageInfo.format || 'markdown', content: this.pageInfo.content || '', isContentEditorAlertDismissed: false, - isContentEditorLoading: true, useContentEditor: false, commitMessage: '', isDirty: false, @@ -164,6 +166,11 @@ export default { linkExample() { return MARKDOWN_LINK_TEXT[this.format]; }, + toggleEditingModeButtonText() { + return this.isContentEditorActive + ? this.$options.i18n.editSourceButtonText + : this.$options.i18n.editRichTextButtonText; + }, submitButtonText() { return this.pageInfo.persisted ? this.$options.i18n.submitButton.existingPage @@ -188,7 +195,23 @@ export default { return this.format === 'markdown'; }, showContentEditorAlert() { - return this.isMarkdownFormat && !this.useContentEditor && !this.isContentEditorAlertDismissed; + return ( + !this.glFeatures.wikiSwitchBetweenContentEditorRawMarkdown && + this.isMarkdownFormat && + !this.useContentEditor && + !this.isContentEditorAlertDismissed + ); + }, + showSwitchEditingModeButton() { + return this.glFeatures.wikiSwitchBetweenContentEditorRawMarkdown && this.isMarkdownFormat; + }, + displayWikiSpecificMarkdownHelp() { + return !this.isContentEditorActive; + }, + displaySwitchBackToClassicEditorMessage() { + return ( + !this.glFeatures.wikiSwitchBetweenContentEditorRawMarkdown && this.isContentEditorActive + ); }, disableSubmitButton() { return this.noContent || !this.title || this.contentEditorRenderFailed; @@ -212,6 +235,14 @@ export default { .then(({ data }) => data.body); }, + toggleEditingMode() { + if (this.useContentEditor) { + this.content = this.contentEditor.getSerializedContent(); + } + + this.useContentEditor = !this.useContentEditor; + }, + async handleFormSubmit(e) { e.preventDefault(); @@ -311,8 +342,11 @@ export default { trackWikiFormat() { this.track(WIKI_FORMAT_UPDATED_ACTION, { label: WIKI_FORMAT_LABEL, - value: this.format, - extra: { project_path: this.pageInfo.path, old_format: this.pageInfo.format }, + extra: { + project_path: this.pageInfo.path, + old_format: this.pageInfo.format, + value: this.format, + }, }); }, @@ -371,10 +405,9 @@ export default { <span class="gl-display-inline-block gl-max-w-full gl-mt-2 gl-text-gray-600"> <gl-icon class="gl-mr-n1" name="bulb" /> {{ titleHelpText }} - <gl-link :href="helpPath" target="_blank" - ><gl-icon name="question-o" /> - {{ $options.i18n.title.helpText.moreInformation }}</gl-link - > + <gl-link :href="helpPath" target="_blank"> + {{ $options.i18n.title.helpText.learnMore }} + </gl-link> </span> </div> </div> @@ -405,6 +438,19 @@ export default { }}</label> </div> <div class="col-sm-10"> + <div + v-if="showSwitchEditingModeButton" + class="gl-display-flex gl-justify-content-end gl-mb-3" + > + <gl-button + data-testid="toggle-editing-mode-button" + data-qa-selector="editing_mode_button" + :data-qa-mode="toggleEditingModeButtonText" + variant="link" + @click="toggleEditingMode" + >{{ toggleEditingModeButtonText }}</gl-button + > + </div> <gl-alert v-if="showContentEditorAlert" class="gl-mb-6" @@ -498,7 +544,7 @@ export default { <div class="error-alert"></div> <div class="form-text gl-text-gray-600"> - <gl-sprintf v-if="!isContentEditorActive" :message="$options.i18n.linksHelpText"> + <gl-sprintf v-if="displayWikiSpecificMarkdownHelp" :message="$options.i18n.linksHelpText"> <template #linkExample ><code>{{ linkExample }}</code></template > @@ -513,7 +559,7 @@ export default { ></template > </gl-sprintf> - <span v-else> + <span v-if="displaySwitchBackToClassicEditorMessage"> {{ $options.i18n.contentEditor.switchToOldEditor.helpText }} <gl-button variant="link" @click="confirmSwitchToOldEditor">{{ $options.i18n.contentEditor.switchToOldEditor.label diff --git a/app/assets/javascripts/pdf/index.vue b/app/assets/javascripts/pdf/index.vue index 6a64538abfe..644eccc0232 100644 --- a/app/assets/javascripts/pdf/index.vue +++ b/app/assets/javascripts/pdf/index.vue @@ -45,7 +45,7 @@ export default { .promise.then(this.renderPages) .then((pages) => { this.pages = pages; - this.$emit('pdflabload'); + this.$emit('pdflabload', pages.length); }) .catch((error) => { this.$emit('pdflaberror', error); diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue index 905a5f2d271..9f82d4a5395 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue @@ -73,7 +73,7 @@ export default { }); }, onReset() { - this.$emit('cancel'); + this.$emit('resetContent'); }, scrollIntoView() { this.$el.scrollIntoView({ behavior: 'smooth' }); @@ -86,7 +86,7 @@ export default { startMergeRequest: __('Start a %{new_merge_request} with these changes'), newMergeRequest: __('new merge request'), commitChanges: __('Commit changes'), - cancel: __('Cancel'), + resetContent: __('Reset'), }, }; </script> @@ -148,7 +148,7 @@ export default { {{ $options.i18n.commitChanges }} </gl-button> <gl-button type="reset" category="secondary" class="gl-mr-3"> - {{ $options.i18n.cancel }} + {{ $options.i18n.resetContent }} </gl-button> </div> </gl-form> diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue index 14c11099756..54c9688d88f 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue @@ -8,10 +8,10 @@ import { COMMIT_SUCCESS, } from '../../constants'; import commitCIFile from '../../graphql/mutations/commit_ci_file.mutation.graphql'; -import updateCurrentBranchMutation from '../../graphql/mutations/update_current_branch.mutation.graphql'; -import updateLastCommitBranchMutation from '../../graphql/mutations/update_last_commit_branch.mutation.graphql'; -import updatePipelineEtag from '../../graphql/mutations/update_pipeline_etag.mutation.graphql'; -import getCurrentBranch from '../../graphql/queries/client/current_branch.graphql'; +import updateCurrentBranchMutation from '../../graphql/mutations/client/update_current_branch.mutation.graphql'; +import updateLastCommitBranchMutation from '../../graphql/mutations/client/update_last_commit_branch.mutation.graphql'; +import updatePipelineEtag from '../../graphql/mutations/client/update_pipeline_etag.mutation.graphql'; +import getCurrentBranch from '../../graphql/queries/client/current_branch.query.graphql'; import CommitForm from './commit_form.vue'; @@ -60,6 +60,9 @@ export default { apollo: { currentBranch: { query: getCurrentBranch, + update(data) { + return data.workBranches.current.name; + }, }, }, computed: { @@ -87,7 +90,7 @@ export default { try { const { data: { - commitCreate: { errors }, + commitCreate: { errors, commitPipelinePath: pipelineEtag }, }, } = await this.$apollo.mutate({ mutation: commitCIFile, @@ -101,14 +104,12 @@ export default { content: this.ciFileContent, lastCommitId: this.commitSha, }, - update(_, { data }) { - const pipelineEtag = data?.commitCreate?.commit?.commitPipelinePath; - if (pipelineEtag) { - this.$apollo.mutate({ mutation: updatePipelineEtag, variables: pipelineEtag }); - } - }, }); + if (pipelineEtag) { + this.updatePipelineEtag(pipelineEtag); + } + if (errors?.length) { this.$emit('showError', { type: COMMIT_FAILURE, reasons: errors }); } else if (openMergeRequest) { @@ -127,9 +128,6 @@ export default { this.isSaving = false; } }, - onCommitCancel() { - this.$emit('resetContent'); - }, updateCurrentBranch(currentBranch) { this.$apollo.mutate({ mutation: updateCurrentBranchMutation, @@ -142,6 +140,9 @@ export default { variables: { lastCommitBranch }, }); }, + updatePipelineEtag(pipelineEtag) { + this.$apollo.mutate({ mutation: updatePipelineEtag, variables: { pipelineEtag } }); + }, }, }; </script> @@ -153,7 +154,6 @@ export default { :is-saving="isSaving" :scroll-to-commit-form="scrollToCommitForm" v-on="$listeners" - @cancel="onCommitCancel" @submit="onCommitSubmit" /> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue index d7594fb318a..7bc096ce2c8 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue +++ b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue @@ -90,7 +90,7 @@ export default { <local-storage-sync v-model="isExpanded" :storage-key="$options.localDrawerKey" as-json> <aside aria-live="polite" - class="gl-fixed gl-right-0 gl-bg-gray-10 gl-shadow-drawer gl-transition-property-width gl-transition-duration-medium gl-border-l-solid gl-border-1 gl-border-gray-100 gl-h-full gl-z-index-3 gl-overflow-y-auto" + class="gl-fixed gl-right-0 gl-bg-gray-10 gl-shadow-drawer gl-transition-property-width gl-transition-duration-medium gl-border-l-solid gl-border-1 gl-border-gray-100 gl-h-full gl-z-index-200 gl-overflow-y-auto" :style="rootStyle" > <gl-button @@ -98,6 +98,7 @@ export default { class="gl-w-full gl-h-9 gl-rounded-0! gl-border-none! gl-border-b-solid! gl-border-1! gl-border-gray-100 gl-text-decoration-none! gl-outline-0! gl-display-flex" :class="buttonClass" :title="__('Toggle sidebar')" + data-qa-selector="toggle_sidebar_collapse_button" @click="toggleDrawer" > <span v-if="isExpanded" class="gl-text-gray-500 gl-mr-3" data-testid="collapse-text"> @@ -105,7 +106,12 @@ export default { </span> <gl-icon data-testid="toggle-icon" :name="buttonIconName" /> </gl-button> - <div v-if="isExpanded" class="gl-h-full gl-p-5" data-testid="drawer-content"> + <div + v-if="isExpanded" + class="gl-h-full gl-p-5" + data-testid="drawer-content" + data-qa-selector="drawer_content" + > <getting-started-card class="gl-mb-4" /> <first-pipeline-card class="gl-mb-4" /> <visualize-and-lint-card class="gl-mb-4" /> diff --git a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue index 7b8e97b573e..92fa411d5af 100644 --- a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue +++ b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue @@ -19,7 +19,7 @@ export default { if (this.glFeatures.schemaLinting) { const editorInstance = this.$refs.editor.getEditor(); - editorInstance.use(new CiSchemaExtension({ instance: editorInstance })); + editorInstance.use({ definition: CiSchemaExtension }); editorInstance.registerCiSchema(); } }, diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue index baf1d17b233..4f79a81d539 100644 --- a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue +++ b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue @@ -18,10 +18,10 @@ import { BRANCH_SEARCH_DEBOUNCE, DEFAULT_FAILURE, } from '~/pipeline_editor/constants'; -import updateCurrentBranchMutation from '~/pipeline_editor/graphql/mutations/update_current_branch.mutation.graphql'; -import getAvailableBranchesQuery from '~/pipeline_editor/graphql/queries/available_branches.graphql'; -import getCurrentBranchQuery from '~/pipeline_editor/graphql/queries/client/current_branch.graphql'; -import getLastCommitBranchQuery from '~/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql'; +import updateCurrentBranchMutation from '~/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql'; +import getAvailableBranchesQuery from '~/pipeline_editor/graphql/queries/available_branches.query.graphql'; +import getCurrentBranch from '~/pipeline_editor/graphql/queries/client/current_branch.query.graphql'; +import getLastCommitBranch from '~/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql'; export default { i18n: { @@ -61,8 +61,8 @@ export default { }, data() { return { - branchSelected: null, availableBranches: [], + branchSelected: null, filteredBranches: [], isSearchingBranches: false, pageLimit: this.paginationLimit, @@ -93,15 +93,25 @@ export default { }, }, currentBranch: { - query: getCurrentBranchQuery, + query: getCurrentBranch, + update(data) { + return data.workBranches.current.name; + }, }, lastCommitBranch: { - query: getLastCommitBranchQuery, - result({ data: { lastCommitBranch } }) { - if (lastCommitBranch === '' || this.availableBranches.includes(lastCommitBranch)) { - return; + query: getLastCommitBranch, + update(data) { + return data.workBranches.lastCommit.name; + }, + result({ data }) { + if (data) { + const { name: lastCommitBranch } = data.workBranches.lastCommit; + if (lastCommitBranch === '' || this.availableBranches.includes(lastCommitBranch)) { + return; + } + + this.availableBranches.unshift(lastCommitBranch); } - this.availableBranches.unshift(lastCommitBranch); }, }, }, @@ -109,12 +119,12 @@ export default { branches() { return this.searchTerm.length > 0 ? this.filteredBranches : this.availableBranches; }, - isBranchesLoading() { - return this.$apollo.queries.availableBranches.loading || this.isSearchingBranches; - }, enableBranchSwitcher() { return this.branches.length > 0 || this.searchTerm.length > 0; }, + isBranchesLoading() { + return this.$apollo.queries.availableBranches.loading || this.isSearchingBranches; + }, }, watch: { shouldLoadNewBranch(flag) { @@ -247,6 +257,7 @@ export default { <gl-infinite-scroll :fetched-items="branches.length" :max-list-height="250" + data-qa-selector="branch_menu_container" @bottomReached="fetchNextBranches" > <template #items> @@ -255,7 +266,7 @@ export default { :key="branch" :is-checked="currentBranch === branch" :is-check-item="true" - data-qa-selector="menu_branch_button" + data-qa-selector="branch_menu_item_button" @click="selectBranch(branch)" > {{ branch }} diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue index 6fe1459c80c..16ad648afca 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue @@ -3,8 +3,8 @@ import { GlButton, GlIcon, GlLink, GlLoadingIcon, GlSprintf, GlTooltipDirective import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { truncateSha } from '~/lib/utils/text_utility'; import { s__ } from '~/locale'; -import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql'; -import getPipelineEtag from '~/pipeline_editor/graphql/queries/client/pipeline_etag.graphql'; +import getPipelineQuery from '~/pipeline_editor/graphql/queries/pipeline.query.graphql'; +import getPipelineEtag from '~/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql'; import { getQueryHeaders, toggleQueryPollingByVisibility, @@ -21,9 +21,6 @@ export const i18n = { ), viewBtn: s__('Pipeline|View pipeline'), viewCommit: s__('Pipeline|View commit'), - pipelineNotTriggeredMsg: s__( - 'Pipeline|No pipeline was triggered for the latest changes due to the current CI/CD configuration.', - ), }; export default { @@ -51,6 +48,9 @@ export default { apollo: { pipelineEtag: { query: getPipelineEtag, + update(data) { + return data.etags.pipeline; + }, }, pipeline: { context() { @@ -79,22 +79,16 @@ export default { result(res) { if (res.data?.project?.pipeline) { this.hasError = false; - } else { - this.hasError = true; - this.pipelineNotTriggered = true; } }, error() { this.hasError = true; - this.networkError = true; }, pollInterval: POLL_INTERVAL, }, }, data() { return { - networkError: false, - pipelineNotTriggered: false, hasError: false, }; }, @@ -148,16 +142,8 @@ export default { </div> </template> <template v-else-if="hasError"> - <div v-if="networkError"> - <gl-icon class="gl-mr-auto" name="warning-solid" /> - <span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span> - </div> - <div v-else> - <gl-icon class="gl-mr-auto" name="information-o" /> - <span data-testid="pipeline-not-triggered-error-msg"> - {{ $options.i18n.pipelineNotTriggeredMsg }} - </span> - </div> + <gl-icon class="gl-mr-auto" name="warning-solid" /> + <span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span> </template> <template v-else> <div class="gl-text-truncate gl-md-max-w-50p gl-mr-1"> diff --git a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue index 611b78b3c5e..833d784f940 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue @@ -1,8 +1,8 @@ <script> import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; -import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.graphql'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.query.graphql'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { EDITOR_APP_STATUS_EMPTY, EDITOR_APP_STATUS_LOADING, @@ -43,6 +43,9 @@ export default { apollo: { appStatus: { query: getAppStatus, + update(data) { + return data.app.status; + }, }, }, computed: { diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue index 7f6dce05b6e..13e254f138a 100644 --- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue +++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue @@ -1,5 +1,5 @@ <script> -import { GlAlert, GlLink, GlSprintf, GlTable } from '@gitlab/ui'; +import { GlAlert, GlLink, GlSprintf, GlTableLite } from '@gitlab/ui'; import { __ } from '~/locale'; import CiLintResultsParam from './ci_lint_results_param.vue'; import CiLintResultsValue from './ci_lint_results_value.vue'; @@ -36,7 +36,7 @@ export default { GlAlert, GlLink, GlSprintf, - GlTable, + GlTableLite, CiLintWarnings, CiLintResultsValue, CiLintResultsParam, @@ -129,7 +129,7 @@ export default { @dismiss="isWarningDismissed = true" /> - <gl-table + <gl-table-lite v-if="shouldShowTable" :items="jobs" :fields="$options.fields" @@ -142,6 +142,6 @@ export default { <template #cell(value)="{ item }"> <ci-lint-results-value :item="item" :dry-run="dryRun" /> </template> - </gl-table> + </gl-table-lite> </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue index 0cd0d17d944..3f50a1225d8 100644 --- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue @@ -17,7 +17,7 @@ import { TABS_INDEX, VISUALIZE_TAB, } from '../constants'; -import getAppStatus from '../graphql/queries/client/app_status.graphql'; +import getAppStatus from '../graphql/queries/client/app_status.query.graphql'; import CiConfigMergedPreview from './editor/ci_config_merged_preview.vue'; import CiEditorHeader from './editor/ci_editor_header.vue'; import TextEditor from './editor/text_editor.vue'; @@ -91,6 +91,9 @@ export default { apollo: { appStatus: { query: getAppStatus, + update(data) { + return data.app.status; + }, }, }, computed: { diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql index 5091d63111f..5091d63111f 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_app_status.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_app_status.mutation.graphql index 7487e328668..7487e328668 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_app_status.mutation.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_app_status.mutation.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_current_branch.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql index b722c147f5f..b722c147f5f 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_current_branch.mutation.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_last_commit_branch.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_last_commit_branch.mutation.graphql index 9561312f2b6..9561312f2b6 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_last_commit_branch.mutation.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_last_commit_branch.mutation.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_pipeline_etag.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql index 9025f00b343..9025f00b343 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_pipeline_etag.mutation.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql index 94e6facabfd..77a3cdf586c 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql @@ -19,7 +19,10 @@ mutation commitCIFile( ] } ) { + __typename commit { + __typename + id sha } commitPipelinePath diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.query.graphql index 46e9b108b41..359b4a846c7 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.query.graphql @@ -5,6 +5,7 @@ query getAvailableBranches( $searchPattern: String! ) { project(fullPath: $projectFullPath) { + id repository { branchNames(limit: $limit, offset: $offset, searchPattern: $searchPattern) } diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.query.graphql index 5500244b430..5928d90f7c4 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.query.graphql @@ -1,8 +1,10 @@ query getBlobContent($projectPath: ID!, $path: String!, $ref: String) { project(fullPath: $projectPath) { + id repository { blobs(paths: [$path], ref: $ref) { nodes { + id rawBlob } } diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.query.graphql index df7de6a1f54..df7de6a1f54 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.query.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.graphql deleted file mode 100644 index 938f36c7d5c..00000000000 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.graphql +++ /dev/null @@ -1,3 +0,0 @@ -query getAppStatus { - appStatus @client -} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.query.graphql new file mode 100644 index 00000000000..0df8cafa3cb --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.query.graphql @@ -0,0 +1,5 @@ +query getAppStatus { + app @client { + status + } +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.graphql deleted file mode 100644 index acd46013f5b..00000000000 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.graphql +++ /dev/null @@ -1,3 +0,0 @@ -query getCurrentBranch { - currentBranch @client -} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.query.graphql new file mode 100644 index 00000000000..1f4f9d26f24 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.query.graphql @@ -0,0 +1,7 @@ +query getCurrentBranch { + workBranches @client { + current { + name + } + } +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql index e8a32d728d5..a83129759de 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql @@ -1,3 +1,7 @@ query getLastCommitBranchQuery { - lastCommitBranch @client + workBranches @client { + lastCommit { + name + } + } } diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.graphql deleted file mode 100644 index b9946a9e233..00000000000 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.graphql +++ /dev/null @@ -1,3 +0,0 @@ -query getPipelineEtag { - pipelineEtag @client -} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql new file mode 100644 index 00000000000..8df6e74a5d9 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql @@ -0,0 +1,5 @@ +query getPipelineEtag { + etags @client { + pipeline + } +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/get_starter_template.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/get_starter_template.query.graphql index 88825718f7b..a34c8f365f4 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/get_starter_template.query.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/get_starter_template.query.graphql @@ -1,5 +1,6 @@ query getTemplate($projectPath: ID!, $templateName: String!) { project(fullPath: $projectPath) { + id ciTemplate(name: $templateName) { content } diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql index 02d49507947..d62fda40237 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql @@ -1,8 +1,10 @@ query getLatestCommitSha($projectPath: ID!, $ref: String) { project(fullPath: $projectPath) { + id repository { tree(ref: $ref) { lastCommit { + id sha } } diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/pipeline.query.graphql index 34e98ae3eb3..021b858d72e 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/pipeline.query.graphql @@ -1,14 +1,17 @@ query getPipeline($fullPath: ID!, $sha: String!) { project(fullPath: $fullPath) { + id pipeline(sha: $sha) { id iid status commit { + id title webPath } detailedStatus { + id detailsPath icon group diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js index e4965e00af3..fa1c70c1994 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js +++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js @@ -1,8 +1,8 @@ import axios from '~/lib/utils/axios_utils'; -import getAppStatus from './queries/client/app_status.graphql'; -import getCurrentBranchQuery from './queries/client/current_branch.graphql'; -import getLastCommitBranchQuery from './queries/client/last_commit_branch.query.graphql'; -import getPipelineEtag from './queries/client/pipeline_etag.graphql'; +import getAppStatus from './queries/client/app_status.query.graphql'; +import getCurrentBranch from './queries/client/current_branch.query.graphql'; +import getLastCommitBranch from './queries/client/last_commit_branch.query.graphql'; +import getPipelineEtag from './queries/client/pipeline_etag.query.graphql'; export const resolvers = { Mutation: { @@ -35,25 +35,51 @@ export const resolvers = { updateAppStatus: (_, { appStatus }, { cache }) => { cache.writeQuery({ query: getAppStatus, - data: { appStatus }, + data: { + app: { + __typename: 'PipelineEditorApp', + status: appStatus, + }, + }, }); }, updateCurrentBranch: (_, { currentBranch }, { cache }) => { cache.writeQuery({ - query: getCurrentBranchQuery, - data: { currentBranch }, + query: getCurrentBranch, + data: { + workBranches: { + __typename: 'BranchList', + current: { + __typename: 'WorkBranch', + name: currentBranch, + }, + }, + }, }); }, updateLastCommitBranch: (_, { lastCommitBranch }, { cache }) => { cache.writeQuery({ - query: getLastCommitBranchQuery, - data: { lastCommitBranch }, + query: getLastCommitBranch, + data: { + workBranches: { + __typename: 'BranchList', + lastCommit: { + __typename: 'WorkBranch', + name: lastCommitBranch, + }, + }, + }, }); }, updatePipelineEtag: (_, { pipelineEtag }, { cache }) => { cache.writeQuery({ query: getPipelineEtag, - data: { pipelineEtag }, + data: { + etags: { + __typename: 'EtagValues', + pipeline: pipelineEtag, + }, + }, }); }, }, diff --git a/app/assets/javascripts/pipeline_editor/graphql/typedefs.graphql b/app/assets/javascripts/pipeline_editor/graphql/typedefs.graphql index f4f65262158..508ff22c46e 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/typedefs.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/typedefs.graphql @@ -1,7 +1,23 @@ -type BlobContent { - rawData: String! +type PipelineEditorApp { + status: String! +} + +type BranchList { + current: WorkBranch! + lastCommit: WorkBranch! +} + +type EtagValues { + pipeline: String! +} + +type WorkBranch { + name: String! + commit: String } extend type Query { - blobContent: BlobContent + app: PipelineEditorApp + etags: EtagValues + workBranches: BranchList } diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js index 4f7f2743aca..ee93e327b76 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -5,10 +5,10 @@ import createDefaultClient from '~/lib/graphql'; import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; import { EDITOR_APP_STATUS_LOADING } from './constants'; import { CODE_SNIPPET_SOURCE_SETTINGS } from './components/code_snippet_alert/constants'; -import getCurrentBranch from './graphql/queries/client/current_branch.graphql'; -import getAppStatus from './graphql/queries/client/app_status.graphql'; -import getLastCommitBranchQuery from './graphql/queries/client/last_commit_branch.query.graphql'; -import getPipelineEtag from './graphql/queries/client/pipeline_etag.graphql'; +import getCurrentBranch from './graphql/queries/client/current_branch.query.graphql'; +import getAppStatus from './graphql/queries/client/app_status.query.graphql'; +import getLastCommitBranch from './graphql/queries/client/last_commit_branch.query.graphql'; +import getPipelineEtag from './graphql/queries/client/pipeline_etag.query.graphql'; import { resolvers } from './graphql/resolvers'; import typeDefs from './graphql/typedefs.graphql'; import PipelineEditorApp from './pipeline_editor_app.vue'; @@ -68,28 +68,46 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { cache.writeQuery({ query: getAppStatus, data: { - appStatus: EDITOR_APP_STATUS_LOADING, + app: { + __typename: 'PipelineEditorApp', + status: EDITOR_APP_STATUS_LOADING, + }, }, }); cache.writeQuery({ query: getCurrentBranch, data: { - currentBranch: initialBranchName || defaultBranch, + workBranches: { + __typename: 'BranchList', + current: { + __typename: 'WorkBranch', + name: initialBranchName || defaultBranch, + }, + }, }, }); cache.writeQuery({ - query: getPipelineEtag, + query: getLastCommitBranch, data: { - pipelineEtag, + workBranches: { + __typename: 'BranchList', + lastCommit: { + __typename: 'WorkBranch', + name: '', + }, + }, }, }); cache.writeQuery({ - query: getLastCommitBranchQuery, + query: getPipelineEtag, data: { - lastCommitBranch: '', + etags: { + __typename: 'EtagValues', + pipeline: pipelineEtag, + }, }, }); diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index 68db5d8078f..e397054f06a 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -1,8 +1,8 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlModal } from '@gitlab/ui'; import { fetchPolicies } from '~/lib/graphql'; import { queryToObject } from '~/lib/utils/url_utility'; -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; @@ -17,11 +17,11 @@ import { LOAD_FAILURE_UNKNOWN, STARTER_TEMPLATE_NAME, } from './constants'; -import updateAppStatus from './graphql/mutations/update_app_status.mutation.graphql'; -import getBlobContent from './graphql/queries/blob_content.graphql'; -import getCiConfigData from './graphql/queries/ci_config.graphql'; -import getAppStatus from './graphql/queries/client/app_status.graphql'; -import getCurrentBranch from './graphql/queries/client/current_branch.graphql'; +import updateAppStatus from './graphql/mutations/client/update_app_status.mutation.graphql'; +import getBlobContent from './graphql/queries/blob_content.query.graphql'; +import getCiConfigData from './graphql/queries/ci_config.query.graphql'; +import getAppStatus from './graphql/queries/client/app_status.query.graphql'; +import getCurrentBranch from './graphql/queries/client/current_branch.query.graphql'; import getTemplate from './graphql/queries/get_starter_template.query.graphql'; import getLatestCommitShaQuery from './graphql/queries/latest_commit_sha.query.graphql'; import PipelineEditorHome from './pipeline_editor_home.vue'; @@ -30,6 +30,7 @@ export default { components: { ConfirmUnsavedChangesDialog, GlLoadingIcon, + GlModal, PipelineEditorEmptyState, PipelineEditorHome, PipelineEditorMessages, @@ -54,6 +55,7 @@ export default { lastCommittedContent: '', shouldSkipStartScreen: false, showFailure: false, + showResetComfirmationModal: false, showStartScreen: false, showSuccess: false, starterTemplate: '', @@ -158,6 +160,9 @@ export default { }, appStatus: { query: getAppStatus, + update(data) { + return data.app.status; + }, }, commitSha: { query: getLatestCommitShaQuery, @@ -182,6 +187,9 @@ export default { }, currentBranch: { query: getCurrentBranch, + update(data) { + return data.workBranches.current.name; + }, }, starterTemplate: { query: getTemplate, @@ -220,9 +228,18 @@ export default { }, }, i18n: { - tabEdit: s__('Pipelines|Edit'), - tabGraph: s__('Pipelines|Visualize'), - tabLint: s__('Pipelines|Lint'), + resetModal: { + actionPrimary: { + text: __('Reset file'), + }, + actionCancel: { + text: __('Cancel'), + }, + body: s__( + 'Pipeline Editor|Are you sure you want to reset the file to its last committed version?', + ), + title: __('Discard changes'), + }, }, watch: { isEmpty(flag) { @@ -242,15 +259,24 @@ export default { hideSuccess() { this.showSuccess = false; }, + confirmReset() { + if (this.hasUnsavedChanges) { + this.showResetComfirmationModal = true; + } + }, async refetchContent() { this.$apollo.queries.initialCiFileContent.skip = false; await this.$apollo.queries.initialCiFileContent.refetch(); }, reportFailure(type, reasons = []) { - window.scrollTo({ top: 0, behavior: 'smooth' }); - this.showFailure = true; - this.failureType = type; - this.failureReasons = reasons; + const isCurrentFailure = this.failureType === type && this.failureReasons[0] === reasons[0]; + + if (!isCurrentFailure) { + this.showFailure = true; + this.failureType = type; + this.failureReasons = reasons; + window.scrollTo({ top: 0, behavior: 'smooth' }); + } }, reportSuccess(type) { window.scrollTo({ top: 0, behavior: 'smooth' }); @@ -258,6 +284,7 @@ export default { this.successType = type; }, resetContent() { + this.showResetComfirmationModal = false; this.currentCiFileContent = this.lastCommittedContent; }, setAppStatus(appStatus) { @@ -331,12 +358,22 @@ export default { :has-unsaved-changes="hasUnsavedChanges" :is-new-ci-config-file="isNewCiConfigFile" @commit="updateOnCommit" - @resetContent="resetContent" + @resetContent="confirmReset" @showError="showErrorAlert" @refetchContent="refetchContent" @updateCiConfig="updateCiConfig" @updateCommitSha="updateCommitSha" /> + <gl-modal + v-model="showResetComfirmationModal" + modal-id="reset-content" + :title="$options.i18n.resetModal.title" + :action-cancel="$options.i18n.resetModal.actionCancel" + :action-primary="$options.i18n.resetModal.actionPrimary" + @primary="resetContent" + > + {{ $options.i18n.resetModal.body }} + </gl-modal> <confirm-unsaved-changes-dialog :has-unsaved-changes="hasUnsavedChanges" /> </div> </div> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue index 3c78b655dc7..1920fed84ec 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue @@ -1,5 +1,5 @@ <script> -import { GlAlert, GlLoadingIcon, GlSegmentedControl, GlToggle } from '@gitlab/ui'; +import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon, GlToggle } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { STAGE_VIEW, LAYER_VIEW } from './constants'; @@ -7,8 +7,9 @@ export default { name: 'GraphViewSelector', components: { GlAlert, + GlButton, + GlButtonGroup, GlLoadingIcon, - GlSegmentedControl, GlToggle, }, props: { @@ -96,6 +97,9 @@ export default { this.hoverTipDismissed = true; this.$emit('dismissHoverTip'); }, + isCurrentType(type) { + return this.segmentSelectedType === type; + }, /* In both toggle methods, we use setTimeout so that the loading indicator displays, then the work is done to update the DOM. The process is: @@ -110,11 +114,14 @@ export default { See https://www.hesselinkwebdesign.nl/2019/nexttick-vs-settimeout-in-vue/ for more details. */ - toggleView(type) { - this.isSwitcherLoading = true; - setTimeout(() => { - this.$emit('updateViewType', type); - }); + setViewType(type) { + if (!this.isCurrentType(type)) { + this.isSwitcherLoading = true; + this.segmentSelectedType = type; + setTimeout(() => { + this.$emit('updateViewType', type); + }); + } }, toggleShowLinksActive(val) { this.isToggleLoading = true; @@ -136,14 +143,16 @@ export default { size="lg" /> <span class="gl-font-weight-bold">{{ $options.i18n.viewLabelText }}</span> - <gl-segmented-control - v-model="segmentSelectedType" - :options="viewTypesList" - :disabled="isSwitcherLoading" - data-testid="pipeline-view-selector" - class="gl-mx-4" - @input="toggleView" - /> + <gl-button-group class="gl-mx-4"> + <gl-button + v-for="viewType in viewTypesList" + :key="viewType.value" + :selected="isCurrentType(viewType.value)" + @click="setViewType(viewType.value)" + > + {{ viewType.text }} + </gl-button> + </gl-button-group> <div v-if="showLinksToggle" class="gl-display-flex gl-align-items-center"> <gl-toggle diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue index 6f4360649ff..12c3f9a7f40 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue @@ -67,7 +67,7 @@ export default { :class="cssClassJobName" class="dropdown-menu-toggle gl-pipeline-job-width! gl-pr-4!" > - <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between"> + <div class="gl-display-flex gl-align-items-stretch gl-justify-content-space-between"> <job-item :type="$options.jobItemTypes.jobDropdown" :group-tooltip="tooltipText" diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index 0216b2717ed..ee58dcc4882 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -203,7 +203,7 @@ export default { <template> <div :id="computedJobId" - class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between gl-w-full" + class="ci-job-component gl-display-flex gl-justify-content-space-between gl-pipeline-job-width" data-qa-selector="job_item_container" > <component @@ -223,12 +223,12 @@ export default { > <div class="ci-job-name-component gl-display-flex gl-align-items-center"> <ci-icon :size="24" :status="job.status" class="gl-line-height-0" /> - <div class="gl-pl-3 gl-display-flex gl-flex-direction-column gl-w-full"> - <div class="gl-text-truncate gl-w-70p gl-line-height-normal">{{ job.name }}</div> + <div class="gl-pl-3 gl-pr-3 gl-display-flex gl-flex-direction-column gl-pipeline-job-width"> + <div class="gl-text-truncate gl-pr-9 gl-line-height-normal">{{ job.name }}</div> <div v-if="showStageName" data-testid="stage-name-in-job" - class="gl-text-truncate gl-w-70p gl-font-sm gl-text-gray-500 gl-line-height-normal" + class="gl-text-truncate gl-pr-9 gl-font-sm gl-text-gray-500 gl-line-height-normal" > {{ stageName }} </div> diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index be47799868b..e0c1dcc5be5 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -124,7 +124,7 @@ export default { <div ref="linkedPipeline" v-gl-tooltip - class="gl-pipeline-job-width" + class="gl-downstream-pipeline-job-width" :title="tooltipText" data-qa-selector="child_pipeline" @mouseover="onDownstreamHovered" @@ -134,7 +134,7 @@ export default { class="gl-relative gl-bg-white gl-p-3 gl-border-solid gl-border-gray-100 gl-border-1" :class="{ 'gl-pl-9': isUpstream }" > - <div class="gl-display-flex"> + <div class="gl-display-flex gl-pr-7 gl-pipeline-job-width"> <ci-status v-if="!pipelineIsLoading" :status="pipelineStatus" @@ -142,7 +142,9 @@ export default { css-classes="gl-top-0 gl-pr-2" /> <div v-else class="gl-pr-2"><gl-loading-icon size="sm" inline /></div> - <div class="gl-display-flex gl-flex-direction-column gl-w-13"> + <div + class="gl-display-flex gl-flex-direction-column gl-pipeline-job-width gl-text-truncate" + > <span class="gl-text-truncate" data-testid="downstream-title"> {{ downstreamTitle }} </span> diff --git a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue new file mode 100644 index 00000000000..ffac8206b58 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue @@ -0,0 +1,121 @@ +<script> +import { GlIntersectionObserver, GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui'; +import produce from 'immer'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; +import eventHub from '~/jobs/components/table/event_hub'; +import JobsTable from '~/jobs/components/table/jobs_table.vue'; +import { JOBS_TAB_FIELDS } from '~/jobs/components/table/constants'; +import getPipelineJobs from '../../graphql/queries/get_pipeline_jobs.query.graphql'; + +export default { + fields: JOBS_TAB_FIELDS, + components: { + GlIntersectionObserver, + GlLoadingIcon, + GlSkeletonLoader, + JobsTable, + }, + inject: { + fullPath: { + default: '', + }, + pipelineIid: { + default: '', + }, + }, + apollo: { + jobs: { + query: getPipelineJobs, + variables() { + return { + ...this.queryVariables, + }; + }, + update(data) { + return data.project?.pipeline?.jobs?.nodes || []; + }, + result({ data }) { + this.jobsPageInfo = data.project?.pipeline?.jobs?.pageInfo || {}; + }, + error() { + createFlash({ message: __('An error occured while fetching the pipelines jobs.') }); + }, + }, + }, + data() { + return { + jobs: [], + jobsPageInfo: {}, + firstLoad: true, + }; + }, + computed: { + queryVariables() { + return { + fullPath: this.fullPath, + iid: this.pipelineIid, + }; + }, + }, + mounted() { + eventHub.$on('jobActionPerformed', this.handleJobAction); + }, + beforeDestroy() { + eventHub.$off('jobActionPerformed', this.handleJobAction); + }, + methods: { + handleJobAction() { + this.firstLoad = true; + + this.$apollo.queries.jobs.refetch(); + }, + fetchMoreJobs() { + this.firstLoad = false; + + this.$apollo.queries.jobs.fetchMore({ + variables: { + ...this.queryVariables, + after: this.jobsPageInfo.endCursor, + }, + updateQuery: (previousResult, { fetchMoreResult }) => { + const results = produce(fetchMoreResult, (draftData) => { + draftData.project.pipeline.jobs.nodes = [ + ...previousResult.project.pipeline.jobs.nodes, + ...draftData.project.pipeline.jobs.nodes, + ]; + }); + return results; + }, + }); + }, + }, +}; +</script> + +<template> + <div> + <div v-if="$apollo.loading && firstLoad" class="gl-mt-5"> + <gl-skeleton-loader :width="1248" :height="73"> + <circle cx="748.031" cy="37.7193" r="15.0307" /> + <circle cx="787.241" cy="37.7193" r="15.0307" /> + <circle cx="827.759" cy="37.7193" r="15.0307" /> + <circle cx="866.969" cy="37.7193" r="15.0307" /> + <circle cx="380" cy="37" r="18" /> + <rect x="432" y="19" width="126.587" height="15" /> + <rect x="432" y="41" width="247" height="15" /> + <rect x="158" y="19" width="86.1" height="15" /> + <rect x="158" y="41" width="168" height="15" /> + <rect x="22" y="19" width="96" height="36" /> + <rect x="924" y="30" width="96" height="15" /> + <rect x="1057" y="20" width="166" height="35" /> + </gl-skeleton-loader> + </div> + + <jobs-table v-else :jobs="jobs" :table-fields="$options.fields" /> + + <gl-intersection-observer v-if="jobsPageInfo.hasNextPage" @appear="fetchMoreJobs"> + <gl-loading-icon v-if="$apollo.loading" size="md" /> + </gl-intersection-observer> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue index 836333c8bde..793e343a02a 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue @@ -1,5 +1,5 @@ <script> -import tooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import tooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; export default { components: { diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue index 78771b6a072..64210576b29 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue @@ -25,7 +25,7 @@ export default { // The max width and the width make sure the ellipsis to work and the min width // is for when there is less text than the stage column width (which the width 100% does not fix) jobWrapperClasses: - 'gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8 gl-min-w-full gl-max-w-15', + 'gl-display-flex gl-flex-direction-column gl-align-items-stretch gl-w-full gl-px-8 gl-min-w-full gl-max-w-15', props: { pipelineData: { required: true, diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue index 367a18af248..e485b38ce11 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue @@ -1,6 +1,6 @@ <script> import { capitalize, escape } from 'lodash'; -import tooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import tooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; export default { components: { diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue index 7552ddb61dc..afcb04cd7eb 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue @@ -15,7 +15,7 @@ import { GlDropdown, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; import eventHub from '../../event_hub'; import JobItem from './job_item.vue'; @@ -98,6 +98,9 @@ export default { // warn the pipelines table to update this.$emit('pipelineActionRequestComplete'); }, + stageAriaLabel(title) { + return sprintf(__('View Stage: %{title}'), { title }); + }, }, }; </script> @@ -106,9 +109,10 @@ export default { <gl-dropdown ref="dropdown" v-gl-tooltip.hover.ds0 + v-gl-tooltip="stage.title" data-testid="mini-pipeline-graph-dropdown" - :title="stage.title" variant="link" + :aria-label="stageAriaLabel(stage.title)" :lazy="true" :popper-opts="{ placement: 'bottom' }" :toggle-class="['mini-pipeline-graph-dropdown-toggle', triggerButtonClass]" diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql index 887c217da41..2a0b13dd0cc 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql +++ b/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql @@ -1,19 +1,24 @@ query getDagVisData($projectPath: ID!, $iid: ID!) { project(fullPath: $projectPath) { + id pipeline(iid: $iid) { id stages { nodes { + id name groups { nodes { + id name size jobs { nodes { + id name needs { nodes { + id name } } diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql index 8fcae9dbad8..47bc167ca52 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql @@ -1,5 +1,6 @@ query getPipelineHeaderData($fullPath: ID!, $iid: ID!) { project(fullPath: $fullPath) { + id pipeline(iid: $iid) { id iid @@ -11,6 +12,7 @@ query getPipelineHeaderData($fullPath: ID!, $iid: ID!) { updatePipeline } detailedStatus { + id detailsPath icon group diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql new file mode 100644 index 00000000000..5fe47e09d9c --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql @@ -0,0 +1,70 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" + +query getPipelineJobs($fullPath: ID!, $iid: ID!, $after: String) { + project(fullPath: $fullPath) { + id + pipeline(iid: $iid) { + id + jobs(after: $after, first: 20) { + pageInfo { + ...PageInfo + } + nodes { + artifacts { + nodes { + downloadPath + fileType + } + } + allowFailure + status + scheduledAt + manualJob + triggered + createdByTag + detailedStatus { + id + detailsPath + group + icon + label + text + tooltip + action { + id + buttonTitle + icon + method + path + title + } + } + id + refName + refPath + tags + shortSha + commitPath + stage { + id + name + } + name + duration + finishedAt + coverage + retryable + playable + cancelable + active + stuck + userPermissions { + readBuild + readJobArtifacts + updateBuild + } + } + } + } + } +} diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index ee9560e36c4..ae8b2503c79 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -3,6 +3,7 @@ import { __ } from '~/locale'; import createDagApp from './pipeline_details_dag'; import { createPipelinesDetailApp } from './pipeline_details_graph'; import { createPipelineHeaderApp } from './pipeline_details_header'; +import { createPipelineJobsApp } from './pipeline_details_jobs'; import { apolloProvider } from './pipeline_shared_client'; import { createTestDetails } from './pipeline_test_details'; @@ -11,6 +12,7 @@ const SELECTORS = { PIPELINE_GRAPH: '#js-pipeline-graph-vue', PIPELINE_HEADER: '#js-pipeline-header-vue', PIPELINE_TESTS: '#js-pipeline-tests-detail', + PIPELINE_JOBS: '#js-pipeline-jobs-vue', }; export default async function initPipelineDetailsBundle() { @@ -55,4 +57,14 @@ export default async function initPipelineDetailsBundle() { message: __('An error occurred while loading the Test Reports tab.'), }); } + + try { + if (gon.features?.jobsTabVue) { + createPipelineJobsApp(SELECTORS.PIPELINE_JOBS); + } + } catch { + createFlash({ + message: __('An error occurred while loading the Jobs tab.'), + }); + } } diff --git a/app/assets/javascripts/pipelines/pipeline_details_jobs.js b/app/assets/javascripts/pipelines/pipeline_details_jobs.js new file mode 100644 index 00000000000..a1294a484f0 --- /dev/null +++ b/app/assets/javascripts/pipelines/pipeline_details_jobs.js @@ -0,0 +1,34 @@ +import { GlToast } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import JobsApp from './components/jobs/jobs_app.vue'; + +Vue.use(VueApollo); +Vue.use(GlToast); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export const createPipelineJobsApp = (selector) => { + const containerEl = document.querySelector(selector); + + if (!containerEl) { + return false; + } + + const { fullPath, pipelineIid } = containerEl.dataset; + + return new Vue({ + el: containerEl, + apolloProvider, + provide: { + fullPath, + pipelineIid, + }, + render(createElement) { + return createElement(JobsApp); + }, + }); +}; diff --git a/app/assets/javascripts/projects/commit/constants.js b/app/assets/javascripts/projects/commit/constants.js index d553bca360e..eb3673461bd 100644 --- a/app/assets/javascripts/projects/commit/constants.js +++ b/app/assets/javascripts/projects/commit/constants.js @@ -11,7 +11,7 @@ export const I18N_MODAL = { 'ChangeTypeAction|Your changes will be committed to %{branchName} because a merge request is open.', ), branchInFork: s__( - 'ChangeTypeAction|A new branch will be created in your fork and a new merge request will be started.', + 'ChangeTypeAction|GitLab will create a branch in your fork and start a merge request.', ), newMergeRequest: __('new merge request'), actionCancelText: __('Cancel'), diff --git a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql b/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql index ee18c70b6fd..c6a0d48626a 100644 --- a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql +++ b/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql @@ -1,15 +1,19 @@ query getLinkedPipelines($fullPath: ID!, $iid: ID!) { project(fullPath: $fullPath) { + id pipeline(iid: $iid) { + id path downstream { nodes { id path project { + id name } detailedStatus { + id group icon label @@ -20,9 +24,11 @@ query getLinkedPipelines($fullPath: ID!, $iid: ID!) { id path project { + id name } detailedStatus { + id group icon label diff --git a/app/assets/javascripts/projects/new/components/new_project_url_select.vue b/app/assets/javascripts/projects/new/components/new_project_url_select.vue index e0ba60074af..f4a21c6057c 100644 --- a/app/assets/javascripts/projects/new/components/new_project_url_select.vue +++ b/app/assets/javascripts/projects/new/components/new_project_url_select.vue @@ -8,7 +8,7 @@ import { GlDropdownSectionHeader, GlSearchBoxByType, } from '@gitlab/ui'; -import { joinPaths } from '~/lib/utils/url_utility'; +import { joinPaths, PATH_SEPARATOR } from '~/lib/utils/url_utility'; import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import Tracking from '~/tracking'; @@ -36,7 +36,9 @@ export default { }; }, skip() { - return this.search.length > 0 && this.search.length < MINIMUM_SEARCH_LENGTH; + const hasNotEnoughSearchCharacters = + this.search.length > 0 && this.search.length < MINIMUM_SEARCH_LENGTH; + return this.shouldSkipQuery || hasNotEnoughSearchCharacters; }, debounce: DEBOUNCE_DELAY, }, @@ -52,7 +54,7 @@ export default { data() { return { currentUser: {}, - groupToFilterBy: undefined, + groupPathToFilterBy: undefined, search: '', selectedNamespace: this.namespaceId ? { @@ -63,6 +65,7 @@ export default { id: this.userNamespaceId, fullPath: this.userNamespaceFullPath, }, + shouldSkipQuery: true, }; }, computed: { @@ -73,10 +76,8 @@ export default { return this.currentUser.namespace || {}; }, filteredGroups() { - return this.groupToFilterBy - ? this.userGroups.filter((group) => - group.fullPath.startsWith(this.groupToFilterBy.fullPath), - ) + return this.groupPathToFilterBy + ? this.userGroups.filter((group) => group.fullPath.startsWith(this.groupPathToFilterBy)) : this.userGroups; }, hasGroupMatches() { @@ -85,7 +86,7 @@ export default { hasNamespaceMatches() { return ( this.userNamespace.fullPath?.toLowerCase().includes(this.search.toLowerCase()) && - !this.groupToFilterBy + !this.groupPathToFilterBy ); }, hasNoMatches() { @@ -99,7 +100,10 @@ export default { eventHub.$off('select-template', this.handleSelectTemplate); }, methods: { - focusInput() { + handleDropdownShown() { + if (this.shouldSkipQuery) { + this.shouldSkipQuery = false; + } this.$refs.search.focusInput(); }, handleDropdownItemClick(namespace) { @@ -111,13 +115,9 @@ export default { }); this.setNamespace(namespace); }, - handleSelectTemplate(groupId) { - this.groupToFilterBy = this.userGroups.find( - (group) => getIdFromGraphQLId(group.id) === groupId, - ); - if (this.groupToFilterBy) { - this.setNamespace(this.groupToFilterBy); - } + handleSelectTemplate(id, fullPath) { + this.groupPathToFilterBy = fullPath.split(PATH_SEPARATOR).shift(); + this.setNamespace({ id, fullPath }); }, setNamespace({ id, fullPath }) { this.selectedNamespace = { @@ -137,7 +137,7 @@ export default { toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20" data-qa-selector="select_namespace_dropdown" @show="track('activate_form_input', { label: trackLabel, property: 'project_path' })" - @shown="focusInput" + @shown="handleDropdownShown" > <gl-search-box-by-type ref="search" diff --git a/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql b/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql index 74febec5a51..568e05d1966 100644 --- a/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql +++ b/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql @@ -1,5 +1,6 @@ query searchNamespacesWhereUserCanCreateProjects($search: String) { currentUser { + id groups(permissionScope: CREATE_PROJECTS, search: $search) { nodes { id diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue index 7379d5caed7..d4b1f7e57d8 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue @@ -1,5 +1,6 @@ <script> import { GlTabs, GlTab } from '@gitlab/ui'; +import API from '~/api'; import { mergeUrlParams, updateHistory, getParameterValues } from '~/lib/utils/url_utility'; import PipelineCharts from './pipeline_charts.vue'; @@ -13,6 +14,9 @@ export default { LeadTimeCharts: () => import('ee_component/dora/components/lead_time_charts.vue'), ProjectQualitySummary: () => import('ee_component/project_quality_summary/app.vue'), }, + piplelinesTabEvent: 'p_analytics_ci_cd_pipelines', + deploymentFrequencyTabEvent: 'p_analytics_ci_cd_deployment_frequency', + leadTimeTabEvent: 'p_analytics_ci_cd_lead_time', inject: { shouldRenderDoraCharts: { type: Boolean, @@ -60,20 +64,35 @@ export default { updateHistory({ url: path, title: window.title }); } }, + trackTabClick(tab) { + API.trackRedisHllUserEvent(tab); + }, }, }; </script> <template> <div> <gl-tabs v-if="charts.length > 1" :value="selectedTab" @input="onTabChange"> - <gl-tab :title="__('Pipelines')"> + <gl-tab + :title="__('Pipelines')" + data-testid="pipelines-tab" + @click="trackTabClick($options.piplelinesTabEvent)" + > <pipeline-charts /> </gl-tab> <template v-if="shouldRenderDoraCharts"> - <gl-tab :title="__('Deployment frequency')"> + <gl-tab + :title="__('Deployment frequency')" + data-testid="deployment-frequency-tab" + @click="trackTabClick($options.deploymentFrequencyTabEvent)" + > <deployment-frequency-charts /> </gl-tab> - <gl-tab :title="__('Lead time')"> + <gl-tab + :title="__('Lead time')" + data-testid="lead-time-tab" + @click="trackTabClick($options.leadTimeTabEvent)" + > <lead-time-charts /> </gl-tab> </template> diff --git a/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue b/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue index 7bc3b787f75..5383a6cdddf 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue @@ -1,10 +1,19 @@ <script> +import { GlLink } from '@gitlab/ui'; import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format'; import { s__, n__ } from '~/locale'; const defaultPrecision = 2; export default { + components: { + GlLink, + }, + inject: { + failedPipelinesLink: { + default: '', + }, + }, props: { counts: { type: Object, @@ -27,6 +36,7 @@ export default { { title: s__('PipelineCharts|Failed:'), value: n__('1 pipeline', '%d pipelines', this.counts.failed), + link: this.failedPipelinesLink, }, { title: s__('PipelineCharts|Success ratio:'), @@ -39,10 +49,13 @@ export default { </script> <template> <ul> - <template v-for="({ title, value }, index) in statistics"> + <template v-for="({ title, value, link }, index) in statistics"> <li :key="index"> <span>{{ title }}</span> - <strong>{{ value }}</strong> + <gl-link v-if="link" :href="link"> + {{ value }} + </gl-link> + <strong v-else>{{ value }}</strong> </li> </template> </ul> diff --git a/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql b/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql index d68df689f5f..ac7fe51384c 100644 --- a/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql +++ b/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql @@ -1,5 +1,6 @@ query getPipelineCountByStatus($projectPath: ID!) { project(fullPath: $projectPath) { + id totalPipelines: pipelines { count } diff --git a/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql b/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql index 18b645f8831..46e8a6dc87d 100644 --- a/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql +++ b/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql @@ -1,5 +1,6 @@ query getProjectPipelineStatistics($projectPath: ID!) { project(fullPath: $projectPath) { + id pipelineAnalytics { weekPipelinesTotals weekPipelinesLabels diff --git a/app/assets/javascripts/projects/pipelines/charts/index.js b/app/assets/javascripts/projects/pipelines/charts/index.js index 003b61d94b1..94d32609e5d 100644 --- a/app/assets/javascripts/projects/pipelines/charts/index.js +++ b/app/assets/javascripts/projects/pipelines/charts/index.js @@ -11,7 +11,7 @@ const apolloProvider = new VueApollo({ }); const mountPipelineChartsApp = (el) => { - const { projectPath } = el.dataset; + const { projectPath, failedPipelinesLink, coverageChartPath, defaultBranch } = el.dataset; const shouldRenderDoraCharts = parseBoolean(el.dataset.shouldRenderDoraCharts); const shouldRenderQualitySummary = parseBoolean(el.dataset.shouldRenderQualitySummary); @@ -25,8 +25,11 @@ const mountPipelineChartsApp = (el) => { apolloProvider, provide: { projectPath, + failedPipelinesLink, shouldRenderDoraCharts, shouldRenderQualitySummary, + coverageChartPath, + defaultBranch, }, render: (createElement) => createElement(ProjectPipelinesCharts, {}), }); diff --git a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue new file mode 100644 index 00000000000..b98e1101884 --- /dev/null +++ b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue @@ -0,0 +1,63 @@ +<script> +import { GlFormGroup } from '@gitlab/ui'; +import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select.vue'; +import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue'; + +export default { + name: 'TransferProjectForm', + components: { + GlFormGroup, + NamespaceSelect, + ConfirmDanger, + }, + props: { + namespaces: { + type: Object, + required: true, + }, + confirmationPhrase: { + type: String, + required: true, + }, + confirmButtonText: { + type: String, + required: true, + }, + }, + data() { + return { selectedNamespace: null }; + }, + computed: { + hasSelectedNamespace() { + return Boolean(this.selectedNamespace?.id); + }, + }, + methods: { + handleSelect(selectedNamespace) { + this.selectedNamespace = selectedNamespace; + this.$emit('selectNamespace', selectedNamespace.id); + }, + }, +}; +</script> +<template> + <div> + <gl-form-group> + <namespace-select + class="qa-namespaces-list" + data-testid="transfer-project-namespace" + :full-width="true" + :data="namespaces" + :selected-namespace="selectedNamespace" + @select="handleSelect" + /> + </gl-form-group> + <confirm-danger + button-class="qa-transfer-button" + :disabled="!hasSelectedNamespace" + :phrase="confirmationPhrase" + :button-text="confirmButtonText" + @confirm="$emit('confirm')" + /> + </div> +</template> diff --git a/app/assets/javascripts/projects/settings/init_transfer_project_form.js b/app/assets/javascripts/projects/settings/init_transfer_project_form.js new file mode 100644 index 00000000000..47b49031dc9 --- /dev/null +++ b/app/assets/javascripts/projects/settings/init_transfer_project_form.js @@ -0,0 +1,53 @@ +import Vue from 'vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import TransferProjectForm from './components/transfer_project_form.vue'; + +const prepareNamespaces = (rawNamespaces = '') => { + const data = JSON.parse(rawNamespaces); + return { + group: data?.group.map(convertObjectPropsToCamelCase), + user: data?.user.map(convertObjectPropsToCamelCase), + }; +}; + +export default () => { + const el = document.querySelector('.js-transfer-project-form'); + if (!el) { + return false; + } + + const { + targetFormId = null, + targetHiddenInputId = null, + buttonText: confirmButtonText = '', + phrase: confirmationPhrase = '', + confirmDangerMessage = '', + namespaces = '', + } = el.dataset; + + return new Vue({ + el, + provide: { + confirmDangerMessage, + }, + render(createElement) { + return createElement(TransferProjectForm, { + props: { + confirmButtonText, + confirmationPhrase, + namespaces: prepareNamespaces(namespaces), + }, + on: { + selectNamespace: (id) => { + if (targetHiddenInputId && document.getElementById(targetHiddenInputId)) { + document.getElementById(targetHiddenInputId).value = id; + } + }, + confirm: () => { + if (targetFormId) document.getElementById(targetFormId)?.submit(); + }, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue index b8053bf9ab5..e5ddfe82e3b 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue @@ -1,5 +1,15 @@ <script> -import { GlButton, GlToggle, GlLoadingIcon, GlSprintf, GlFormInput, GlLink } from '@gitlab/ui'; +import { + GlButton, + GlToggle, + GlLoadingIcon, + GlSprintf, + GlFormInputGroup, + GlFormGroup, + GlFormInput, + GlLink, +} from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { __ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ServiceDeskTemplateDropdown from './service_desk_template_dropdown.vue'; @@ -15,6 +25,8 @@ export default { GlLoadingIcon, GlSprintf, GlFormInput, + GlFormGroup, + GlFormInputGroup, GlLink, ServiceDeskTemplateDropdown, }, @@ -88,6 +100,16 @@ export default { hasCustomEmail() { return this.customEmail && this.customEmail !== this.incomingEmail; }, + emailSuffixHelpUrl() { + return helpPagePath('user/project/service_desk.html', { + anchor: 'configuring-a-custom-email-address-suffix', + }); + }, + customEmailAddressHelpUrl() { + return helpPagePath('user/project/service_desk.html', { + anchor: 'using-a-custom-email-address', + }); + }, }, methods: { onCheckboxToggle(isChecked) { @@ -132,101 +154,122 @@ export default { </label> <div v-if="isEnabled" class="row mt-3"> <div class="col-md-9 mb-0"> - <strong - id="incoming-email-describer" - class="gl-display-block gl-mb-1" - data-testid="incoming-email-describer" + <gl-form-group + :label="__('Email address to use for Support Desk')" + label-for="incoming-email" + data-testid="incoming-email-label" > - {{ __('Email address to use for Support Desk') }} - </strong> - <template v-if="email"> - <div class="input-group"> - <input + <gl-form-input-group v-if="email"> + <gl-form-input + id="incoming-email" ref="service-desk-incoming-email" type="text" - class="form-control" data-testid="incoming-email" :placeholder="__('Incoming email')" :aria-label="__('Incoming email')" aria-describedby="incoming-email-describer" :value="email" - disabled="true" + :disabled="true" /> - <div class="input-group-append"> + <template #append> <clipboard-button :title="__('Copy')" :text="email" css-class="input-group-text" /> - </div> - </div> - <span v-if="hasCustomEmail" class="form-text text-muted"> - <gl-sprintf :message="__('Emails sent to %{email} are also supported.')"> - <template #email> - <code>{{ incomingEmail }}</code> + </template> + </gl-form-input-group> + <template v-if="email && hasCustomEmail" #description> + <span class="gl-mt-2 d-inline-block"> + <gl-sprintf :message="__('Emails sent to %{email} are also supported.')"> + <template #email> + <code>{{ incomingEmail }}</code> + </template> + </gl-sprintf> + </span> + </template> + <template v-if="!email"> + <gl-loading-icon size="sm" :inline="true" /> + <span class="sr-only">{{ __('Fetching incoming email') }}</span> + </template> + </gl-form-group> + + <gl-form-group :label="__('Email address suffix')" :state="!projectKeyError"> + <gl-form-input + v-if="hasProjectKeySupport" + id="service-desk-project-suffix" + v-model.trim="projectKey" + data-testid="project-suffix" + @blur="validateProjectKey" + /> + + <template v-if="hasProjectKeySupport" #description> + <gl-sprintf + :message=" + __('Add a suffix to Service Desk email address. %{linkStart}Learn more.%{linkEnd}') + " + > + <template #link="{ content }"> + <gl-link + :href="emailSuffixHelpUrl" + target="_blank" + class="gl-text-blue-600 font-size-inherit" + >{{ content }} + </gl-link> </template> </gl-sprintf> - </span> - </template> - <template v-else> - <gl-loading-icon size="sm" :inline="true" /> - <span class="sr-only">{{ __('Fetching incoming email') }}</span> - </template> + </template> + <template v-else #description> + <gl-sprintf + :message=" + __( + 'To add a custom suffix, set up a Service Desk email address. %{linkStart}Learn more.%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link + :href="customEmailAddressHelpUrl" + target="_blank" + class="gl-text-blue-600 font-size-inherit" + >{{ content }} + </gl-link> + </template> + </gl-sprintf> + </template> + + <template v-if="hasProjectKeySupport && projectKeyError" #invalid-feedback> + {{ projectKeyError }} + </template> + </gl-form-group> - <label for="service-desk-project-suffix" class="mt-3"> - {{ __('Project name suffix') }} - </label> - <gl-form-input - v-if="hasProjectKeySupport" - id="service-desk-project-suffix" - v-model.trim="projectKey" - data-testid="project-suffix" - class="form-control" + <gl-form-group + :label="__('Template to append to all Service Desk issues')" :state="!projectKeyError" - @blur="validateProjectKey" - /> - <span v-if="hasProjectKeySupport && projectKeyError" class="form-text text-danger"> - {{ projectKeyError }} - </span> - <span - v-if="hasProjectKeySupport" - class="form-text text-muted" - :class="{ 'gl-mt-2!': hasProjectKeySupport && projectKeyError }" + class="mt-3" > - {{ __('A string appended to the project path to form the Service Desk email address.') }} - </span> - <span v-else class="form-text text-muted"> - <gl-sprintf - :message=" - __( - 'To add a custom suffix, set up a Service Desk email address. %{linkStart}Learn more.%{linkEnd}', - ) - " - > - <template #link="{ content }"> - <gl-link - href="https://docs.gitlab.com/ee/user/project/service_desk.html#using-a-custom-email-address" - target="_blank" - class="gl-text-blue-600 font-size-inherit" - >{{ content }} - </gl-link> - </template> - </gl-sprintf> - </span> + <service-desk-template-dropdown + :selected-template="selectedTemplate" + :selected-file-template-project-id="selectedFileTemplateProjectId" + :templates="templates" + @change="templateChange" + /> + </gl-form-group> + + <gl-form-group + :label="__('Email display name')" + label-for="service-desk-email-from-name" + :state="!projectKeyError" + class="mt-3" + > + <gl-form-input + v-if="hasProjectKeySupport" + id="service-desk-email-from-name" + v-model.trim="outgoingName" + data-testid="email-from-name" + /> - <label for="service-desk-template-select" class="mt-3"> - {{ __('Template to append to all Service Desk issues') }} - </label> - <service-desk-template-dropdown - :selected-template="selectedTemplate" - :selected-file-template-project-id="selectedFileTemplateProjectId" - :templates="templates" - @change="templateChange" - /> + <template v-if="hasProjectKeySupport" #description> + {{ __('Emails sent from Service Desk have this name.') }} + </template> + </gl-form-group> - <label for="service-desk-email-from-name" class="mt-3"> - {{ __('Email display name') }} - </label> - <input id="service-desk-email-from-name" v-model.trim="outgoingName" class="form-control" /> - <span class="form-text text-muted"> - {{ __('Emails sent from Service Desk have this name.') }} - </span> <div class="gl-display-flex gl-justify-content-end"> <gl-button variant="success" diff --git a/app/assets/javascripts/projects/storage_counter/components/app.vue b/app/assets/javascripts/projects/storage_counter/components/app.vue deleted file mode 100644 index 1a911ea3d9b..00000000000 --- a/app/assets/javascripts/projects/storage_counter/components/app.vue +++ /dev/null @@ -1,106 +0,0 @@ -<script> -import { GlAlert, GlLink, GlLoadingIcon } from '@gitlab/ui'; -import { sprintf } from '~/locale'; -import UsageGraph from '~/vue_shared/components/storage_counter/usage_graph.vue'; -import { - ERROR_MESSAGE, - LEARN_MORE_LABEL, - USAGE_QUOTAS_LABEL, - TOTAL_USAGE_TITLE, - TOTAL_USAGE_SUBTITLE, - TOTAL_USAGE_DEFAULT_TEXT, - HELP_LINK_ARIA_LABEL, -} from '../constants'; -import getProjectStorageCount from '../queries/project_storage.query.graphql'; -import { parseGetProjectStorageResults } from '../utils'; -import StorageTable from './storage_table.vue'; - -export default { - name: 'StorageCounterApp', - components: { - GlAlert, - GlLink, - GlLoadingIcon, - StorageTable, - UsageGraph, - }, - inject: ['projectPath', 'helpLinks'], - apollo: { - project: { - query: getProjectStorageCount, - variables() { - return { - fullPath: this.projectPath, - }; - }, - update(data) { - return parseGetProjectStorageResults(data, this.helpLinks); - }, - error() { - this.error = ERROR_MESSAGE; - }, - }, - }, - data() { - return { - project: {}, - error: '', - }; - }, - computed: { - totalUsage() { - return this.project?.storage?.totalUsage || TOTAL_USAGE_DEFAULT_TEXT; - }, - storageTypes() { - return this.project?.storage?.storageTypes || []; - }, - }, - methods: { - clearError() { - this.error = ''; - }, - helpLinkAriaLabel(linkTitle) { - return sprintf(HELP_LINK_ARIA_LABEL, { - linkTitle, - }); - }, - }, - LEARN_MORE_LABEL, - USAGE_QUOTAS_LABEL, - TOTAL_USAGE_TITLE, - TOTAL_USAGE_SUBTITLE, -}; -</script> -<template> - <gl-loading-icon v-if="$apollo.queries.project.loading" class="gl-mt-5" size="md" /> - <gl-alert v-else-if="error" variant="danger" @dismiss="clearError"> - {{ error }} - </gl-alert> - <div v-else> - <div class="gl-pt-5 gl-px-3"> - <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> - <div> - <p class="gl-m-0 gl-font-lg gl-font-weight-bold">{{ $options.TOTAL_USAGE_TITLE }}</p> - <p class="gl-m-0 gl-text-gray-400"> - {{ $options.TOTAL_USAGE_SUBTITLE }} - <gl-link - :href="helpLinks.usageQuotasHelpPagePath" - target="_blank" - :aria-label="helpLinkAriaLabel($options.USAGE_QUOTAS_LABEL)" - data-testid="usage-quotas-help-link" - > - {{ $options.LEARN_MORE_LABEL }} - </gl-link> - </p> - </div> - <p class="gl-m-0 gl-font-size-h-display gl-font-weight-bold" data-testid="total-usage"> - {{ totalUsage }} - </p> - </div> - </div> - <div v-if="project.statistics" class="gl-w-full"> - <usage-graph :root-storage-statistics="project.statistics" :limit="0" /> - </div> - <storage-table :storage-types="storageTypes" /> - </div> -</template> diff --git a/app/assets/javascripts/projects/storage_counter/components/storage_table.vue b/app/assets/javascripts/projects/storage_counter/components/storage_table.vue deleted file mode 100644 index a42a9711572..00000000000 --- a/app/assets/javascripts/projects/storage_counter/components/storage_table.vue +++ /dev/null @@ -1,88 +0,0 @@ -<script> -import { GlLink, GlIcon, GlTableLite as GlTable, GlSprintf } from '@gitlab/ui'; -import { numberToHumanSize } from '~/lib/utils/number_utils'; -import { thWidthClass } from '~/lib/utils/table_utility'; -import { sprintf } from '~/locale'; -import { PROJECT_TABLE_LABELS, HELP_LINK_ARIA_LABEL } from '../constants'; -import StorageTypeIcon from './storage_type_icon.vue'; - -export default { - name: 'StorageTable', - components: { - GlLink, - GlIcon, - GlTable, - GlSprintf, - StorageTypeIcon, - }, - props: { - storageTypes: { - type: Array, - required: true, - }, - }, - methods: { - helpLinkAriaLabel(linkTitle) { - return sprintf(HELP_LINK_ARIA_LABEL, { - linkTitle, - }); - }, - }, - projectTableFields: [ - { - key: 'storageType', - label: PROJECT_TABLE_LABELS.STORAGE_TYPE, - thClass: thWidthClass(90), - sortable: true, - }, - { - key: 'value', - label: PROJECT_TABLE_LABELS.VALUE, - thClass: thWidthClass(10), - sortable: true, - formatter: (value) => { - return numberToHumanSize(value, 1); - }, - }, - ], -}; -</script> -<template> - <gl-table :items="storageTypes" :fields="$options.projectTableFields"> - <template #cell(storageType)="{ item }"> - <div class="gl-display-flex gl-flex-direction-row"> - <storage-type-icon - :name="item.storageType.id" - :data-testid="`${item.storageType.id}-icon`" - /> - <div> - <p class="gl-font-weight-bold gl-mb-0" :data-testid="`${item.storageType.id}-name`"> - {{ item.storageType.name }} - <gl-link - v-if="item.storageType.helpPath" - :href="item.storageType.helpPath" - target="_blank" - :aria-label="helpLinkAriaLabel(item.storageType.name)" - :data-testid="`${item.storageType.id}-help-link`" - > - <gl-icon name="question" :size="12" /> - </gl-link> - </p> - <p class="gl-mb-0" :data-testid="`${item.storageType.id}-description`"> - {{ item.storageType.description }} - </p> - <p v-if="item.storageType.warningMessage" class="gl-mb-0 gl-font-sm"> - <gl-icon name="warning" :size="12" /> - <gl-sprintf :message="item.storageType.warningMessage"> - <template #warningLink="{ content }"> - <gl-link :href="item.storageType.warningLink" target="_blank" class="gl-font-sm">{{ - content - }}</gl-link> - </template> - </gl-sprintf> - </p> - </div> - </div> - </template> - </gl-table> -</template> diff --git a/app/assets/javascripts/projects/storage_counter/components/storage_type_icon.vue b/app/assets/javascripts/projects/storage_counter/components/storage_type_icon.vue deleted file mode 100644 index bc7cd42df1e..00000000000 --- a/app/assets/javascripts/projects/storage_counter/components/storage_type_icon.vue +++ /dev/null @@ -1,35 +0,0 @@ -<script> -import { GlIcon } from '@gitlab/ui'; - -export default { - components: { GlIcon }, - props: { - name: { - type: String, - required: false, - default: '', - }, - }, - methods: { - iconName(storageTypeName) { - const defaultStorageTypeIcon = 'disk'; - const storageTypeIconMap = { - lfsObjectsSize: 'doc-image', - snippetsSize: 'snippet', - uploadsSize: 'upload', - repositorySize: 'infrastructure-registry', - packagesSize: 'package', - }; - - return storageTypeIconMap[`${storageTypeName}`] ?? defaultStorageTypeIcon; - }, - }, -}; -</script> -<template> - <span - class="gl-display-inline-flex gl-align-items-flex-start gl-justify-content-center gl-min-w-8 gl-pr-2 gl-pt-1" - > - <gl-icon :name="iconName(name)" :size="16" class="gl-mt-1" /> - </span> -</template> diff --git a/app/assets/javascripts/projects/storage_counter/constants.js b/app/assets/javascripts/projects/storage_counter/constants.js deleted file mode 100644 index df4b1800dff..00000000000 --- a/app/assets/javascripts/projects/storage_counter/constants.js +++ /dev/null @@ -1,61 +0,0 @@ -import { s__, __ } from '~/locale'; - -export const PROJECT_STORAGE_TYPES = [ - { - id: 'buildArtifactsSize', - name: s__('UsageQuota|Artifacts'), - description: s__('UsageQuota|Pipeline artifacts and job artifacts, created with CI/CD.'), - warningMessage: s__( - 'UsageQuota|Because of a known issue, the artifact total for some projects may be incorrect. For more details, read %{warningLinkStart}the epic%{warningLinkEnd}.', - ), - warningLink: 'https://gitlab.com/groups/gitlab-org/-/epics/5380', - }, - { - id: 'lfsObjectsSize', - name: s__('UsageQuota|LFS storage'), - description: s__('UsageQuota|Audio samples, videos, datasets, and graphics.'), - }, - { - id: 'packagesSize', - name: s__('UsageQuota|Packages'), - description: s__('UsageQuota|Code packages and container images.'), - }, - { - id: 'repositorySize', - name: s__('UsageQuota|Repository'), - description: s__('UsageQuota|Git repository.'), - }, - { - id: 'snippetsSize', - name: s__('UsageQuota|Snippets'), - description: s__('UsageQuota|Shared bits of code and text.'), - }, - { - id: 'uploadsSize', - name: s__('UsageQuota|Uploads'), - description: s__('UsageQuota|File attachments and smaller design graphics.'), - }, - { - id: 'wikiSize', - name: s__('UsageQuota|Wiki'), - description: s__('UsageQuota|Wiki content.'), - }, -]; - -export const PROJECT_TABLE_LABELS = { - STORAGE_TYPE: s__('UsageQuota|Storage type'), - VALUE: s__('UsageQuota|Usage'), -}; - -export const ERROR_MESSAGE = s__( - 'UsageQuota|Something went wrong while fetching project storage statistics', -); - -export const LEARN_MORE_LABEL = __('Learn more.'); -export const USAGE_QUOTAS_LABEL = s__('UsageQuota|Usage Quotas'); -export const HELP_LINK_ARIA_LABEL = s__('UsageQuota|%{linkTitle} help link'); -export const TOTAL_USAGE_DEFAULT_TEXT = __('N/A'); -export const TOTAL_USAGE_TITLE = s__('UsageQuota|Usage breakdown'); -export const TOTAL_USAGE_SUBTITLE = s__( - 'UsageQuota|Includes artifacts, repositories, wiki, uploads, and other items.', -); diff --git a/app/assets/javascripts/projects/storage_counter/index.js b/app/assets/javascripts/projects/storage_counter/index.js deleted file mode 100644 index 15796bc1870..00000000000 --- a/app/assets/javascripts/projects/storage_counter/index.js +++ /dev/null @@ -1,51 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; -import StorageCounterApp from './components/app.vue'; - -Vue.use(VueApollo); - -export default (containerId = 'js-project-storage-count-app') => { - const el = document.getElementById(containerId); - - if (!el) { - return false; - } - - const { - projectPath, - usageQuotasHelpPagePath, - buildArtifactsHelpPagePath, - lfsObjectsHelpPagePath, - packagesHelpPagePath, - repositoryHelpPagePath, - snippetsHelpPagePath, - uploadsHelpPagePath, - wikiHelpPagePath, - } = el.dataset; - - const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), - }); - - return new Vue({ - el, - apolloProvider, - provide: { - projectPath, - helpLinks: { - usageQuotasHelpPagePath, - buildArtifactsHelpPagePath, - lfsObjectsHelpPagePath, - packagesHelpPagePath, - repositoryHelpPagePath, - snippetsHelpPagePath, - uploadsHelpPagePath, - wikiHelpPagePath, - }, - }, - render(createElement) { - return createElement(StorageCounterApp); - }, - }); -}; diff --git a/app/assets/javascripts/projects/storage_counter/queries/project_storage.query.graphql b/app/assets/javascripts/projects/storage_counter/queries/project_storage.query.graphql deleted file mode 100644 index a4f2c529522..00000000000 --- a/app/assets/javascripts/projects/storage_counter/queries/project_storage.query.graphql +++ /dev/null @@ -1,16 +0,0 @@ -query getProjectStorageCount($fullPath: ID!) { - project(fullPath: $fullPath) { - id - statistics { - buildArtifactsSize - pipelineArtifactsSize - lfsObjectsSize - packagesSize - repositorySize - snippetsSize - storageSize - uploadsSize - wikiSize - } - } -} diff --git a/app/assets/javascripts/projects/storage_counter/utils.js b/app/assets/javascripts/projects/storage_counter/utils.js deleted file mode 100644 index 9fca9d88f46..00000000000 --- a/app/assets/javascripts/projects/storage_counter/utils.js +++ /dev/null @@ -1,36 +0,0 @@ -import { numberToHumanSize } from '~/lib/utils/number_utils'; -import { PROJECT_STORAGE_TYPES } from './constants'; - -/** - * This method parses the results from `getProjectStorageCount` call. - * - * @param {Object} data graphql result - * @returns {Object} - */ -export const parseGetProjectStorageResults = (data, helpLinks) => { - const projectStatistics = data?.project?.statistics; - if (!projectStatistics) { - return {}; - } - const { storageSize, ...storageStatistics } = projectStatistics; - const storageTypes = PROJECT_STORAGE_TYPES.reduce((types, currentType) => { - const helpPathKey = currentType.id.replace(`Size`, `HelpPagePath`); - const helpPath = helpLinks[helpPathKey]; - - return types.concat({ - storageType: { - ...currentType, - helpPath, - }, - value: storageStatistics[currentType.id], - }); - }, []); - - return { - storage: { - totalUsage: numberToHumanSize(storageSize, 1), - storageTypes, - }, - statistics: projectStatistics, - }; -}; diff --git a/app/assets/javascripts/related_issues/components/issue_token.vue b/app/assets/javascripts/related_issues/components/issue_token.vue index abbd612d3ec..61bd2bf5e8e 100644 --- a/app/assets/javascripts/related_issues/components/issue_token.vue +++ b/app/assets/javascripts/related_issues/components/issue_token.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; -import relatedIssuableMixin from '~/vue_shared/mixins/related_issuable_mixin'; +import relatedIssuableMixin from '~/issuable/mixins/related_issuable_mixin'; export default { name: 'IssueToken', diff --git a/app/assets/javascripts/related_issues/components/related_issues_list.vue b/app/assets/javascripts/related_issues/components/related_issues_list.vue index a21e294a34a..58138655241 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_list.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue @@ -2,7 +2,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import Sortable from 'sortablejs'; import sortableConfig from '~/sortable/sortable_config'; -import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; +import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue'; export default { name: 'RelatedIssuesList', diff --git a/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql index 3a927dfc756..8a5613c75d2 100644 --- a/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql +++ b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql @@ -35,6 +35,7 @@ fragment Release on Release { __typename nodes { __typename + id filepath collectedAt sha @@ -52,12 +53,14 @@ fragment Release on Release { } commit { __typename + id sha webUrl title } author { __typename + id webUrl avatarUrl username diff --git a/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql index 75a73acb9ae..1823a327350 100644 --- a/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql +++ b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql @@ -18,6 +18,7 @@ fragment ReleaseForEditing on Release { } milestones { nodes { + id title } } diff --git a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql index c69481150e0..7f67f7d11a3 100644 --- a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql +++ b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql @@ -16,6 +16,7 @@ query allReleasesDeprecated( ) { project(fullPath: $fullPath) { __typename + id releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) { __typename nodes { diff --git a/app/assets/javascripts/releases/graphql/queries/one_release.query.graphql b/app/assets/javascripts/releases/graphql/queries/one_release.query.graphql index c80d6e753ab..dab92d5d41c 100644 --- a/app/assets/javascripts/releases/graphql/queries/one_release.query.graphql +++ b/app/assets/javascripts/releases/graphql/queries/one_release.query.graphql @@ -2,6 +2,7 @@ query oneRelease($fullPath: ID!, $tagName: String!) { project(fullPath: $fullPath) { + id release(tagName: $tagName) { ...Release } diff --git a/app/assets/javascripts/releases/graphql/queries/one_release_for_editing.query.graphql b/app/assets/javascripts/releases/graphql/queries/one_release_for_editing.query.graphql index 767ba4aeca0..962d554303a 100644 --- a/app/assets/javascripts/releases/graphql/queries/one_release_for_editing.query.graphql +++ b/app/assets/javascripts/releases/graphql/queries/one_release_for_editing.query.graphql @@ -2,6 +2,7 @@ query oneReleaseForEditing($fullPath: ID!, $tagName: String!) { project(fullPath: $fullPath) { + id release(tagName: $tagName) { ...ReleaseForEditing } diff --git a/app/assets/javascripts/repository/commits_service.js b/app/assets/javascripts/repository/commits_service.js index 504efaea8cc..5fd9cfd4e53 100644 --- a/app/assets/javascripts/repository/commits_service.js +++ b/app/assets/javascripts/repository/commits_service.js @@ -52,14 +52,9 @@ export const loadCommits = async (projectPath, path, ref, offset) => { } // We fetch in batches of 25, so this ensures we don't refetch - Array.from(Array(COMMIT_BATCH_SIZE)).forEach((_, i) => { - addRequestedOffset(offset - i); - addRequestedOffset(offset + i); - }); + Array.from(Array(COMMIT_BATCH_SIZE)).forEach((_, i) => addRequestedOffset(offset + i)); - // Since a user could scroll either up or down, we want to support lazy loading in both directions - const commitsBatchUp = await fetchData(projectPath, path, ref, offset - COMMIT_BATCH_SIZE); - const commitsBatchDown = await fetchData(projectPath, path, ref, offset); + const commits = await fetchData(projectPath, path, ref, offset); - return commitsBatchUp.concat(commitsBatchDown); + return commits; }; diff --git a/app/assets/javascripts/repository/components/blob_button_group.vue b/app/assets/javascripts/repository/components/blob_button_group.vue index 4e7ca7b17e4..6f540bf8ece 100644 --- a/app/assets/javascripts/repository/components/blob_button_group.vue +++ b/app/assets/javascripts/repository/components/blob_button_group.vue @@ -53,6 +53,10 @@ export default { type: Boolean, required: true, }, + canPushToBranch: { + type: Boolean, + required: true, + }, emptyRepo: { type: Boolean, required: true, @@ -83,6 +87,9 @@ export default { deleteModalTitle() { return sprintf(__('Delete %{name}'), { name: this.name }); }, + lockBtnQASelector() { + return this.canLock ? 'lock_button' : 'disabled_lock_button'; + }, }, }; </script> @@ -98,6 +105,7 @@ export default { :is-locked="isLocked" :can-lock="canLock" data-testid="lock" + :data-qa-selector="lockBtnQASelector" /> <gl-button v-gl-modal="replaceModalId" data-testid="replace"> {{ $options.i18n.replace }} @@ -125,6 +133,7 @@ export default { :target-branch="targetBranch || ref" :original-branch="originalBranch || ref" :can-push-code="canPushCode" + :can-push-to-branch="canPushToBranch" :empty-repo="emptyRepo" /> </div> diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index 2cc5a8a79d2..f3fa4526999 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -106,6 +106,7 @@ export default { ideForkAndEditPath: '', storedExternally: false, canModifyBlob: false, + canCurrentUserPushToBranch: false, rawPath: '', externalStorageUrl: '', replacePath: '', @@ -156,11 +157,18 @@ export default { }, canLock() { const { pushCode, downloadCode } = this.project.userPermissions; + const currentUsername = window.gon?.current_username; + + if (this.pathLockedByUser && this.pathLockedByUser.username !== currentUsername) { + return false; + } return pushCode && downloadCode; }, - isLocked() { - return this.project.pathLocks.nodes.some((node) => node.path === this.path); + pathLockedByUser() { + const pathLock = this.project.pathLocks.nodes.find((node) => node.path === this.path); + + return pathLock ? pathLock.user : null; }, showForkSuggestion() { const { createMergeRequestIn, forkProject } = this.project.userPermissions; @@ -266,9 +274,10 @@ export default { :replace-path="blobInfo.replacePath" :delete-path="blobInfo.webPath" :can-push-code="project.userPermissions.pushCode" + :can-push-to-branch="blobInfo.canCurrentUserPushToBranch" :empty-repo="project.repository.empty" :project-path="projectPath" - :is-locked="isLocked" + :is-locked="Boolean(pathLockedByUser)" :can-lock="canLock" /> </template> diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js index c5209d97abb..8f6f2d15215 100644 --- a/app/assets/javascripts/repository/components/blob_viewers/index.js +++ b/app/assets/javascripts/repository/components/blob_viewers/index.js @@ -3,8 +3,11 @@ export const loadViewer = (type) => { case 'empty': return () => import(/* webpackChunkName: 'blob_empty_viewer' */ './empty_viewer.vue'); case 'text': - return gon.features.refactorTextViewer - ? () => import(/* webpackChunkName: 'blob_text_viewer' */ './text_viewer.vue') + return gon.features.highlightJs + ? () => + import( + /* webpackChunkName: 'blob_text_viewer' */ '~/vue_shared/components/source_viewer.vue' + ) : null; case 'download': return () => import(/* webpackChunkName: 'blob_download_viewer' */ './download_viewer.vue'); @@ -12,6 +15,8 @@ export const loadViewer = (type) => { return () => import(/* webpackChunkName: 'blob_image_viewer' */ './image_viewer.vue'); case 'video': return () => import(/* webpackChunkName: 'blob_video_viewer' */ './video_viewer.vue'); + case 'pdf': + return () => import(/* webpackChunkName: 'blob_pdf_viewer' */ './pdf_viewer.vue'); default: return null; } @@ -21,8 +26,7 @@ export const viewerProps = (type, blob) => { return { text: { content: blob.rawTextBlob, - fileName: blob.name, - readOnly: true, + autoDetect: true, // We'll eventually disable autoDetect and pass the language explicitly to reduce the footprint (https://gitlab.com/gitlab-org/gitlab/-/issues/348145) }, download: { fileName: blob.name, @@ -36,5 +40,9 @@ export const viewerProps = (type, blob) => { video: { url: blob.rawPath, }, + pdf: { + url: blob.rawPath, + fileSize: blob.rawSize, + }, }[type]; }; diff --git a/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue new file mode 100644 index 00000000000..803a357df52 --- /dev/null +++ b/app/assets/javascripts/repository/components/blob_viewers/pdf_viewer.vue @@ -0,0 +1,50 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import PdfViewer from '~/blob/pdf/pdf_viewer.vue'; +import { __ } from '~/locale'; +import { PDF_MAX_FILE_SIZE, PDF_MAX_PAGE_LIMIT } from '../../constants'; + +export default { + components: { GlButton, PdfViewer }, + i18n: { + tooLargeDescription: __('This PDF is too large to display. Please download to view.'), + tooLargeButtonText: __('Download PDF'), + }, + props: { + url: { + type: String, + required: true, + }, + fileSize: { + type: Number, + required: true, + }, + }, + data() { + return { totalPages: 0 }; + }, + computed: { + tooLargeToDisplay() { + return this.fileSize > PDF_MAX_FILE_SIZE || this.totalPages > PDF_MAX_PAGE_LIMIT; + }, + }, + methods: { + handleOnLoad(totalPages) { + this.totalPages = totalPages; + }, + }, +}; +</script> +<template> + <div> + <pdf-viewer v-if="!tooLargeToDisplay" :pdf="url" @pdflabload="handleOnLoad" /> + + <div v-else class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-p-5"> + <p>{{ $options.i18n.tooLargeDescription }}</p> + + <gl-button icon="download" category="secondary" variant="confirm" :href="url" download>{{ + $options.i18n.tooLargeButtonText + }}</gl-button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/repository/components/blob_viewers/text_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/text_viewer.vue deleted file mode 100644 index 57fc979a56e..00000000000 --- a/app/assets/javascripts/repository/components/blob_viewers/text_viewer.vue +++ /dev/null @@ -1,25 +0,0 @@ -<script> -export default { - components: { - SourceEditor: () => - import(/* webpackChunkName: 'SourceEditor' */ '~/vue_shared/components/source_editor.vue'), - }, - props: { - content: { - type: String, - required: true, - }, - fileName: { - type: String, - required: true, - }, - readOnly: { - type: Boolean, - required: true, - }, - }, -}; -</script> -<template> - <source-editor :value="content" :file-name="fileName" :editor-options="{ readOnly }" /> -</template> diff --git a/app/assets/javascripts/repository/components/delete_blob_modal.vue b/app/assets/javascripts/repository/components/delete_blob_modal.vue index 4a8cedb60b4..0d3dc06c2c8 100644 --- a/app/assets/javascripts/repository/components/delete_blob_modal.vue +++ b/app/assets/javascripts/repository/components/delete_blob_modal.vue @@ -71,6 +71,10 @@ export default { type: Boolean, required: true, }, + canPushToBranch: { + type: Boolean, + required: true, + }, emptyRepo: { type: Boolean, required: true, @@ -176,9 +180,12 @@ export default { </template> <template v-else> <input type="hidden" name="original_branch" :value="originalBranch" /> - <!-- Once "push to branch" permission is made available, will need to add to conditional - Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/335462 --> - <input v-if="createNewMr" type="hidden" name="create_merge_request" value="1" /> + <input + v-if="createNewMr || !canPushToBranch" + type="hidden" + name="create_merge_request" + value="1" + /> <gl-form-group :label="$options.i18n.COMMIT_LABEL" label-for="commit_message" @@ -188,6 +195,7 @@ export default { v-model="form.fields['commit_message'].value" v-validation:[form.showValidation] name="commit_message" + data-qa-selector="commit_message_field" :state="form.fields['commit_message'].state" :disabled="loading" required diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 62066973ee6..43e114a91d3 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -111,7 +111,7 @@ export default { </script> <template> - <div class="info-well d-none d-sm-flex project-last-commit commit p-3"> + <div class="well-segment commit gl-p-5 gl-w-full"> <gl-loading-icon v-if="isLoading" size="md" color="dark" class="m-auto" /> <template v-else-if="commit"> <user-avatar-link diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index bd06c064ab7..8fcec5fb893 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -13,7 +13,7 @@ import { import { escapeRegExp } from 'lodash'; import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql'; import { escapeFileUrl } from '~/lib/utils/url_utility'; -import { TREE_PAGE_SIZE } from '~/repository/constants'; +import { TREE_PAGE_SIZE, ROW_APPEAR_DELAY } from '~/repository/constants'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -128,6 +128,7 @@ export default { return { commit: null, hasRowAppeared: false, + delayedRowAppear: null, }; }, computed: { @@ -202,14 +203,19 @@ export default { rowAppeared() { this.hasRowAppeared = true; + if (this.commitInfo) { + return; + } + if (this.glFeatures.lazyLoadCommits) { - this.$emit('row-appear', { - rowNumber: this.rowNumber, - hasCommit: Boolean(this.commitInfo), - }); + this.delayedRowAppear = setTimeout( + () => this.$emit('row-appear', this.rowNumber), + ROW_APPEAR_DELAY, + ); } }, rowDisappeared() { + clearTimeout(this.delayedRowAppear); this.hasRowAppeared = false; }, }, diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index ffe8d5531f8..130ebf77361 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -3,7 +3,12 @@ import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.g import createFlash from '~/flash'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { __ } from '../../locale'; -import { TREE_PAGE_SIZE, TREE_INITIAL_FETCH_COUNT, TREE_PAGE_LIMIT } from '../constants'; +import { + TREE_PAGE_SIZE, + TREE_INITIAL_FETCH_COUNT, + TREE_PAGE_LIMIT, + COMMIT_BATCH_SIZE, +} from '../constants'; import getRefMixin from '../mixins/get_ref'; import projectPathQuery from '../queries/project_path.query.graphql'; import { readmeFile } from '../utils/readme'; @@ -151,11 +156,19 @@ export default { .concat(data.trees.pageInfo, data.submodules.pageInfo, data.blobs.pageInfo) .find(({ hasNextPage }) => hasNextPage); }, - loadCommitData({ rowNumber = 0, hasCommit } = {}) { - if (!this.glFeatures.lazyLoadCommits || hasCommit || isRequested(rowNumber)) { + handleRowAppear(rowNumber) { + if (!this.glFeatures.lazyLoadCommits || isRequested(rowNumber)) { return; } + // Since a user could scroll either up or down, we want to support lazy loading in both directions + this.loadCommitData(rowNumber); + + if (rowNumber - COMMIT_BATCH_SIZE >= 0) { + this.loadCommitData(rowNumber - COMMIT_BATCH_SIZE); + } + }, + loadCommitData(rowNumber) { loadCommits(this.projectPath, this.path, this.ref, rowNumber) .then(this.setCommitData) .catch(() => {}); @@ -182,7 +195,7 @@ export default { :has-more="hasShowMore" :commits="commits" @showMore="handleShowMore" - @row-appear="loadCommitData" + @row-appear="handleRowAppear" /> <file-preview v-if="readme" :blob="readme" /> </div> diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue index 11e5b5608cb..b56c9ce5247 100644 --- a/app/assets/javascripts/repository/components/upload_blob_modal.vue +++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue @@ -24,10 +24,10 @@ import { } from '../constants'; const PRIMARY_OPTIONS_TEXT = __('Upload file'); -const MODAL_TITLE = __('Upload New File'); +const MODAL_TITLE = __('Upload new file'); const REMOVE_FILE_TEXT = __('Remove file'); const NEW_BRANCH_IN_FORK = __( - 'A new branch will be created in your fork and a new merge request will be started.', + 'GitLab will create a branch in your fork and start a merge request.', ); const ERROR_MESSAGE = __('Error uploading file. Please try again.'); diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js index 152fabbd7cc..d01757d6141 100644 --- a/app/assets/javascripts/repository/constants.js +++ b/app/assets/javascripts/repository/constants.js @@ -11,7 +11,7 @@ export const COMMIT_LABEL = __('Commit message'); export const TARGET_BRANCH_LABEL = __('Target branch'); export const TOGGLE_CREATE_MR_LABEL = __('Start a new merge request with these changes'); export const NEW_BRANCH_IN_FORK = __( - 'A new branch will be created in your fork and a new merge request will be started.', + 'GitLab will create a branch in your fork and start a merge request.', ); export const COMMIT_MESSAGE_SUBJECT_MAX_LENGTH = 52; @@ -20,3 +20,8 @@ export const COMMIT_MESSAGE_BODY_MAX_LENGTH = 72; export const LIMITED_CONTAINER_WIDTH_CLASS = 'limit-container-width'; export const I18N_COMMIT_DATA_FETCH_ERROR = __('An error occurred while fetching commit data.'); + +export const PDF_MAX_FILE_SIZE = 10000000; // 10 MB +export const PDF_MAX_PAGE_LIMIT = 50; + +export const ROW_APPEAR_DELAY = 150; diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 45e026ad695..197b19387cf 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -188,5 +188,5 @@ export default function setupVueRepositoryList() { }, }); - return { router, data: dataset }; + return { router, data: dataset, apolloProvider, projectPath }; } diff --git a/app/assets/javascripts/repository/mutations/lock_path.mutation.graphql b/app/assets/javascripts/repository/mutations/lock_path.mutation.graphql index eaebc4ddf17..0851564bb24 100644 --- a/app/assets/javascripts/repository/mutations/lock_path.mutation.graphql +++ b/app/assets/javascripts/repository/mutations/lock_path.mutation.graphql @@ -4,6 +4,7 @@ mutation toggleLock($projectPath: ID!, $filePath: String!, $lock: Boolean!) { id pathLocks { nodes { + id path } } diff --git a/app/assets/javascripts/repository/pages/tree.vue b/app/assets/javascripts/repository/pages/tree.vue index cbdc62624d4..6bf674eb3f1 100644 --- a/app/assets/javascripts/repository/pages/tree.vue +++ b/app/assets/javascripts/repository/pages/tree.vue @@ -1,5 +1,5 @@ <script> -import TreeContent from '../components/tree_content.vue'; +import TreeContent from 'jh_else_ce/repository/components/tree_content.vue'; import preloadMixin from '../mixins/preload'; import { updateElementsVisibility } from '../utils/dom'; diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql index cf3892802fd..45d1ba80917 100644 --- a/app/assets/javascripts/repository/queries/blob_info.query.graphql +++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql @@ -9,13 +9,19 @@ query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) { } pathLocks { nodes { + id path + user { + id + username + } } } repository { empty blobs(paths: [$filePath], ref: $ref) { nodes { + id webPath name size @@ -28,6 +34,7 @@ query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) { forkAndEditPath ideForkAndEditPath canModifyBlob + canCurrentUserPushToBranch storedExternally rawPath replacePath diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 3c8533dd06d..ee9533bbec3 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -3,7 +3,6 @@ import $ from 'jquery'; import Cookies from 'js-cookie'; import { hide, fixTitle } from '~/tooltips'; -import { DEBOUNCE_DROPDOWN_DELAY } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import createFlash from './flash'; import axios from './lib/utils/axios_utils'; import { sprintf, s__, __ } from './locale'; @@ -127,14 +126,6 @@ Sidebar.prototype.openDropdown = function (blockOrName) { this.setCollapseAfterUpdate($block); this.toggleSidebar('open'); } - - // Wait for the sidebar to trigger('click') open - // so it doesn't cause our dropdown to close preemptively - setTimeout(() => { - if (!gon.features?.labelsWidget && !$block.hasClass('labels-select-wrapper')) { - $block.find('.js-sidebar-dropdown-toggle').trigger('click'); - } - }, DEBOUNCE_DROPDOWN_DELAY); }; Sidebar.prototype.setCollapseAfterUpdate = function ($block) { diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue index 3edb658eaf5..f8220553db6 100644 --- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue @@ -3,12 +3,12 @@ import { GlBadge, GlLink } from '@gitlab/ui'; import createFlash from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; import { updateHistory } from '~/lib/utils/url_utility'; -import { sprintf, __ } from '~/locale'; import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; import RunnerList from '../components/runner_list.vue'; import RunnerName from '../components/runner_name.vue'; +import RunnerOnlineStat from '../components/stat/runner_online_stat.vue'; import RunnerPagination from '../components/runner_pagination.vue'; import RunnerTypeTabs from '../components/runner_type_tabs.vue'; @@ -38,6 +38,7 @@ export default { RunnerFilteredSearchBar, RunnerList, RunnerName, + RunnerOnlineStat, RunnerPagination, RunnerTypeTabs, }, @@ -110,17 +111,12 @@ export default { noRunnersFound() { return !this.runnersLoading && !this.runners.items.length; }, - activeRunnersMessage() { - return sprintf(__('Runners currently online: %{active_runners_count}'), { - active_runners_count: this.activeRunnersCount, - }); - }, searchTokens() { return [ statusTokenConfig, { ...tagTokenConfig, - recentTokenValuesStorageKey: `${this.$options.filteredSearchNamespace}-recent-tags`, + recentSuggestionsStorageKey: `${this.$options.filteredSearchNamespace}-recent-tags`, }, ]; }, @@ -165,6 +161,8 @@ export default { </script> <template> <div> + <runner-online-stat class="gl-py-6 gl-px-5" :value="activeRunnersCount" /> + <div class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0" > @@ -194,11 +192,7 @@ export default { v-model="search" :tokens="searchTokens" :namespace="$options.filteredSearchNamespace" - > - <template #runner-count> - {{ activeRunnersMessage }} - </template> - </runner-filtered-search-bar> + /> <div v-if="noRunnersFound" class="gl-text-center gl-p-5"> {{ __('No runners found') }} diff --git a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue index c4bddb7b398..33f7a67aba4 100644 --- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue @@ -1,27 +1,29 @@ <script> -import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlButtonGroup, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; import createFlash from '~/flash'; -import { __, s__ } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql'; import runnerActionsUpdateMutation from '~/runner/graphql/runner_actions_update.mutation.graphql'; import { captureException } from '~/runner/sentry_utils'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import RunnerDeleteModal from '../runner_delete_modal.vue'; -const i18n = { - I18N_EDIT: __('Edit'), - I18N_PAUSE: __('Pause'), - I18N_RESUME: __('Resume'), - I18N_REMOVE: __('Remove'), - I18N_REMOVE_CONFIRMATION: s__('Runners|Are you sure you want to delete this runner?'), -}; +const I18N_EDIT = __('Edit'); +const I18N_PAUSE = __('Pause'); +const I18N_RESUME = __('Resume'); +const I18N_DELETE = s__('Runners|Delete runner'); +const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted'); export default { name: 'RunnerActionsCell', components: { GlButton, GlButtonGroup, + RunnerDeleteModal, }, directives: { GlTooltip: GlTooltipDirective, + GlModal: GlModalDirective, }, props: { runner: { @@ -48,21 +50,29 @@ export default { // mouseout listeners don't run leaving the tooltip stuck return ''; } - return this.isActive ? i18n.I18N_PAUSE : i18n.I18N_RESUME; + return this.isActive ? I18N_PAUSE : I18N_RESUME; }, deleteTitle() { - // Prevent a "sticky" tooltip: If element gets removed, - // mouseout listeners don't run and leaving the tooltip stuck - return this.deleting ? '' : i18n.I18N_REMOVE; + if (this.deleting) { + // Prevent a "sticky" tooltip: If this button is disabled, + // mouseout listeners don't run leaving the tooltip stuck + return ''; + } + return I18N_DELETE; + }, + runnerId() { + return getIdFromGraphQLId(this.runner.id); + }, + runnerName() { + return `#${this.runnerId} (${this.runner.shortSha})`; + }, + runnerDeleteModalId() { + return `delete-runner-modal-${this.runnerId}`; }, }, methods: { async onToggleActive() { this.updating = true; - // TODO In HAML iteration we had a confirmation modal via: - // data-confirm="_('Are you sure?')" - // this may not have to ported, this is an easily reversible operation - try { const toggledActive = !this.runner.active; @@ -91,12 +101,8 @@ export default { }, async onDelete() { - // TODO Replace confirmation with gl-modal - // eslint-disable-next-line no-alert - if (!window.confirm(i18n.I18N_REMOVE_CONFIRMATION)) { - return; - } - + // Deleting stays "true" until this row is removed, + // should only change back if the operation fails. this.deleting = true; try { const { @@ -115,11 +121,13 @@ export default { }); if (errors && errors.length) { throw new Error(errors.join(' ')); + } else { + // Use $root to have the toast message stay after this element is removed + this.$root.$toast?.show(sprintf(I18N_DELETED_TOAST, { name: this.runnerName })); } } catch (e) { - this.onError(e); - } finally { this.deleting = false; + this.onError(e); } }, @@ -133,14 +141,15 @@ export default { captureException({ error, component: this.$options.name }); }, }, - i18n, + I18N_EDIT, + I18N_DELETE, }; </script> <template> <gl-button-group> <!-- - This button appears for administratos: those with + This button appears for administrators: those with access to the adminUrl. More advanced permissions policies will allow more granular permissions. @@ -148,16 +157,14 @@ export default { --> <gl-button v-if="runner.adminUrl" - v-gl-tooltip.hover.viewport + v-gl-tooltip.hover.viewport="$options.I18N_EDIT" :href="runner.adminUrl" - :title="$options.i18n.I18N_EDIT" - :aria-label="$options.i18n.I18N_EDIT" + :aria-label="$options.I18N_EDIT" icon="pencil" data-testid="edit-runner" /> <gl-button - v-gl-tooltip.hover.viewport - :title="toggleActiveTitle" + v-gl-tooltip.hover.viewport="toggleActiveTitle" :aria-label="toggleActiveTitle" :icon="toggleActiveIcon" :loading="updating" @@ -165,14 +172,20 @@ export default { @click="onToggleActive" /> <gl-button - v-gl-tooltip.hover.viewport - :title="deleteTitle" + v-gl-tooltip.hover.viewport="deleteTitle" + v-gl-modal="runnerDeleteModalId" :aria-label="deleteTitle" icon="close" :loading="deleting" variant="danger" data-testid="delete-runner" - @click="onDelete" + /> + + <runner-delete-modal + :ref="runnerDeleteModalId" + :modal-id="runnerDeleteModalId" + :runner-name="runnerName" + @primary="onDelete" /> </gl-button-group> </template> diff --git a/app/assets/javascripts/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/runner/components/cells/runner_status_cell.vue index 9ba1192bc8c..473cd7e9794 100644 --- a/app/assets/javascripts/runner/components/cells/runner_status_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_status_cell.vue @@ -1,14 +1,12 @@ <script> import { GlTooltipDirective } from '@gitlab/ui'; -import RunnerContactedStateBadge from '../runner_contacted_state_badge.vue'; +import RunnerStatusBadge from '../runner_status_badge.vue'; import RunnerPausedBadge from '../runner_paused_badge.vue'; -import { I18N_LOCKED_RUNNER_DESCRIPTION, I18N_PAUSED_RUNNER_DESCRIPTION } from '../../constants'; - export default { components: { - RunnerContactedStateBadge, + RunnerStatusBadge, RunnerPausedBadge, }, directives: { @@ -25,16 +23,12 @@ export default { return !this.runner.active; }, }, - i18n: { - I18N_LOCKED_RUNNER_DESCRIPTION, - I18N_PAUSED_RUNNER_DESCRIPTION, - }, }; </script> <template> <div> - <runner-contacted-state-badge :runner="runner" size="sm" /> + <runner-status-badge :runner="runner" size="sm" /> <runner-paused-badge v-if="paused" size="sm" /> </div> </template> diff --git a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue index 3b476997915..937ec631633 100644 --- a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue +++ b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import RunnerName from '../runner_name.vue'; import RunnerTypeBadge from '../runner_type_badge.vue'; diff --git a/app/assets/javascripts/runner/components/runner_delete_modal.vue b/app/assets/javascripts/runner/components/runner_delete_modal.vue new file mode 100644 index 00000000000..8be216a7eb5 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_delete_modal.vue @@ -0,0 +1,51 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; + +const I18N_TITLE = s__('Runners|Delete runner %{name}?'); +const I18N_BODY = s__( + 'Runners|The runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?', +); +const I18N_PRIMARY = s__('Runners|Delete runner'); +const I18N_CANCEL = __('Cancel'); + +export default { + components: { + GlModal, + }, + props: { + runnerName: { + type: String, + required: true, + }, + }, + computed: { + title() { + return sprintf(I18N_TITLE, { name: this.runnerName }); + }, + }, + methods: { + onPrimary() { + this.$refs.modal.hide(); + }, + }, + actionPrimary: { text: I18N_PRIMARY, attributes: { variant: 'danger' } }, + actionCancel: { text: I18N_CANCEL }, + I18N_BODY, +}; +</script> + +<template> + <gl-modal + ref="modal" + size="sm" + :title="title" + :action-primary="$options.actionPrimary" + :action-cancel="$options.actionCancel" + v-bind="$attrs" + v-on="$listeners" + @primary="onPrimary" + > + {{ $options.I18N_BODY }} + </gl-modal> +</template> diff --git a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue index a9dfec35479..f0f8bbdf5df 100644 --- a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue +++ b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue @@ -76,24 +76,18 @@ export default { }; </script> <template> - <div + <filtered-search class="gl-bg-gray-10 gl-p-5 gl-border-solid gl-border-gray-100 gl-border-0 gl-border-t-1 gl-border-b-1" - > - <filtered-search - v-bind="$attrs" - :namespace="namespace" - recent-searches-storage-key="runners-search" - :sort-options="$options.sortOptions" - :initial-filter-value="initialFilterValue" - :tokens="tokens" - :initial-sort-by="initialSortBy" - :search-input-placeholder="__('Search or filter results...')" - data-testid="runners-filtered-search" - @onFilter="onFilter" - @onSort="onSort" - /> - <div class="gl-text-right" data-testid="runner-count"> - <slot name="runner-count"></slot> - </div> - </div> + v-bind="$attrs" + :namespace="namespace" + recent-searches-storage-key="runners-search" + :sort-options="$options.sortOptions" + :initial-filter-value="initialFilterValue" + :tokens="tokens" + :initial-sort-by="initialSortBy" + :search-input-placeholder="__('Search or filter results...')" + data-testid="runners-filtered-search" + @onFilter="onFilter" + @onSort="onSort" + /> </template> diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue index f8dbc469c22..023308dbac2 100644 --- a/app/assets/javascripts/runner/components/runner_list.vue +++ b/app/assets/javascripts/runner/components/runner_list.vue @@ -1,27 +1,26 @@ <script> import { GlTable, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { __, s__ } from '~/locale'; +import { formatNumber, __, s__ } from '~/locale'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { RUNNER_JOB_COUNT_LIMIT } from '../constants'; import RunnerActionsCell from './cells/runner_actions_cell.vue'; import RunnerSummaryCell from './cells/runner_summary_cell.vue'; import RunnerStatusCell from './cells/runner_status_cell.vue'; import RunnerTags from './runner_tags.vue'; -const tableField = ({ key, label = '', width = 10 }) => { +const tableField = ({ key, label = '', thClasses = [] }) => { return { key, label, thClass: [ - `gl-w-${width}p`, 'gl-bg-transparent!', 'gl-border-b-solid!', 'gl-border-b-gray-100!', - 'gl-py-5!', - 'gl-px-0!', 'gl-border-b-1!', + ...thClasses, ], - tdClass: ['gl-py-5!', 'gl-px-1!'], tdAttr: { 'data-testid': `td-${key}`, }, @@ -32,6 +31,7 @@ export default { components: { GlTable, GlSkeletonLoader, + TooltipOnTruncate, TimeAgo, RunnerActionsCell, RunnerSummaryCell, @@ -53,6 +53,12 @@ export default { }, }, methods: { + formatJobCount(jobCount) { + if (jobCount > RUNNER_JOB_COUNT_LIMIT) { + return `${formatNumber(RUNNER_JOB_COUNT_LIMIT)}+`; + } + return formatNumber(jobCount); + }, runnerTrAttr(runner) { if (runner) { return { @@ -64,10 +70,11 @@ export default { }, fields: [ tableField({ key: 'status', label: s__('Runners|Status') }), - tableField({ key: 'summary', label: s__('Runners|Runner ID'), width: 30 }), + tableField({ key: 'summary', label: s__('Runners|Runner ID'), thClasses: ['gl-lg-w-25p'] }), tableField({ key: 'version', label: __('Version') }), tableField({ key: 'ipAddress', label: __('IP Address') }), - tableField({ key: 'tagList', label: __('Tags'), width: 20 }), + tableField({ key: 'jobCount', label: __('Jobs') }), + tableField({ key: 'tagList', label: __('Tags'), thClasses: ['gl-lg-w-25p'] }), tableField({ key: 'contactedAt', label: __('Last contact') }), tableField({ key: 'actions', label: '' }), ], @@ -82,6 +89,7 @@ export default { :tbody-tr-attr="runnerTrAttr" data-testid="runner-list" stacked="md" + primary-key="id" fixed > <template v-if="!runners.length" #table-busy> @@ -101,11 +109,19 @@ export default { </template> <template #cell(version)="{ item: { version } }"> - {{ version }} + <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="version"> + {{ version }} + </tooltip-on-truncate> </template> <template #cell(ipAddress)="{ item: { ipAddress } }"> - {{ ipAddress }} + <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="ipAddress"> + {{ ipAddress }} + </tooltip-on-truncate> + </template> + + <template #cell(jobCount)="{ item: { jobCount } }"> + {{ formatJobCount(jobCount) }} </template> <template #cell(tagList)="{ item: { tagList } }"> diff --git a/app/assets/javascripts/runner/components/runner_contacted_state_badge.vue b/app/assets/javascripts/runner/components/runner_status_badge.vue index b4727f832f8..0823876a187 100644 --- a/app/assets/javascripts/runner/components/runner_contacted_state_badge.vue +++ b/app/assets/javascripts/runner/components/runner_status_badge.vue @@ -1,14 +1,17 @@ <script> import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; -import { s__, sprintf } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; import { getTimeago } from '~/lib/utils/datetime_utility'; import { - I18N_ONLINE_RUNNER_DESCRIPTION, - I18N_OFFLINE_RUNNER_DESCRIPTION, + I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION, I18N_NOT_CONNECTED_RUNNER_DESCRIPTION, + I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION, + I18N_STALE_RUNNER_DESCRIPTION, STATUS_ONLINE, - STATUS_OFFLINE, STATUS_NOT_CONNECTED, + STATUS_NEVER_CONTACTED, + STATUS_OFFLINE, + STATUS_STALE, } from '../constants'; export default { @@ -29,31 +32,39 @@ export default { if (this.runner.contactedAt) { return getTimeago().format(this.runner.contactedAt); } - return null; + // Prevent "just now" from being rendered, in case data is missing. + return __('n/a'); }, badge() { - switch (this.runner.status) { + switch (this.runner?.status) { case STATUS_ONLINE: return { variant: 'success', label: s__('Runners|online'), - tooltip: sprintf(I18N_ONLINE_RUNNER_DESCRIPTION, { + tooltip: sprintf(I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION, { timeAgo: this.contactedAtTimeAgo, }), }; + case STATUS_NOT_CONNECTED: + case STATUS_NEVER_CONTACTED: + return { + variant: 'muted', + label: s__('Runners|not connected'), + tooltip: I18N_NOT_CONNECTED_RUNNER_DESCRIPTION, + }; case STATUS_OFFLINE: return { variant: 'muted', label: s__('Runners|offline'), - tooltip: sprintf(I18N_OFFLINE_RUNNER_DESCRIPTION, { + tooltip: sprintf(I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION, { timeAgo: this.contactedAtTimeAgo, }), }; - case STATUS_NOT_CONNECTED: + case STATUS_STALE: return { - variant: 'muted', - label: s__('Runners|not connected'), - tooltip: I18N_NOT_CONNECTED_RUNNER_DESCRIPTION, + variant: 'warning', + label: s__('Runners|stale'), + tooltip: I18N_STALE_RUNNER_DESCRIPTION, }; default: return null; diff --git a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js index 9963048ae1d..4b356fa47ed 100644 --- a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js +++ b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js @@ -7,6 +7,7 @@ import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_NOT_CONNECTED, + STATUS_STALE, PARAM_KEY_STATUS, } from '../../constants'; @@ -16,6 +17,7 @@ const options = [ { value: STATUS_ONLINE, title: s__('Runners|Online') }, { value: STATUS_OFFLINE, title: s__('Runners|Offline') }, { value: STATUS_NOT_CONNECTED, title: s__('Runners|Not connected') }, + { value: STATUS_STALE, title: s__('Runners|Stale') }, ]; export const statusTokenConfig = { diff --git a/app/assets/javascripts/runner/components/search_tokens/tag_token.vue b/app/assets/javascripts/runner/components/search_tokens/tag_token.vue index ab67ac608e2..7461308ab91 100644 --- a/app/assets/javascripts/runner/components/search_tokens/tag_token.vue +++ b/app/assets/javascripts/runner/components/search_tokens/tag_token.vue @@ -68,7 +68,6 @@ export default { :config="config" :suggestions-loading="loading" :suggestions="tags" - :recent-suggestions-storage-key="config.recentTokenValuesStorageKey" @fetch-suggestions="fetchTags" v-on="$listeners" > diff --git a/app/assets/javascripts/runner/components/stat/runner_online_stat.vue b/app/assets/javascripts/runner/components/stat/runner_online_stat.vue new file mode 100644 index 00000000000..b92b9badef0 --- /dev/null +++ b/app/assets/javascripts/runner/components/stat/runner_online_stat.vue @@ -0,0 +1,17 @@ +<script> +import { GlSingleStat } from '@gitlab/ui/dist/charts'; + +export default { + components: { + GlSingleStat, + }, +}; +</script> +<template> + <gl-single-stat + v-bind="$attrs" + variant="success" + :title="s__('Runners|Online Runners')" + :meta-text="s__('Runners|online')" + /> +</template> diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js index 3952e2398e0..355f3054917 100644 --- a/app/assets/javascripts/runner/constants.js +++ b/app/assets/javascripts/runner/constants.js @@ -1,6 +1,7 @@ import { s__ } from '~/locale'; export const RUNNER_PAGE_SIZE = 20; +export const RUNNER_JOB_COUNT_LIMIT = 1000; export const GROUP_RUNNER_COUNT_LIMIT = 1000; export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.'); @@ -14,15 +15,18 @@ export const I18N_GROUP_RUNNER_DESCRIPTION = s__( export const I18N_PROJECT_RUNNER_DESCRIPTION = s__('Runners|Associated with one or more projects'); // Status -export const I18N_ONLINE_RUNNER_DESCRIPTION = s__( +export const I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION = s__( 'Runners|Runner is online; last contact was %{timeAgo}', ); -export const I18N_OFFLINE_RUNNER_DESCRIPTION = s__( - 'Runners|No recent contact from this runner; last contact was %{timeAgo}', -); export const I18N_NOT_CONNECTED_RUNNER_DESCRIPTION = s__( 'Runners|This runner has never connected to this instance', ); +export const I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION = s__( + 'Runners|No recent contact from this runner; last contact was %{timeAgo}', +); +export const I18N_STALE_RUNNER_DESCRIPTION = s__( + 'Runners|No contact from this runner in over 3 months', +); export const I18N_LOCKED_RUNNER_DESCRIPTION = s__('Runners|You cannot assign to other projects'); export const I18N_PAUSED_RUNNER_DESCRIPTION = s__('Runners|Not available to run jobs'); @@ -54,9 +58,12 @@ export const PROJECT_TYPE = 'PROJECT_TYPE'; export const STATUS_ACTIVE = 'ACTIVE'; export const STATUS_PAUSED = 'PAUSED'; + export const STATUS_ONLINE = 'ONLINE'; -export const STATUS_OFFLINE = 'OFFLINE'; export const STATUS_NOT_CONNECTED = 'NOT_CONNECTED'; +export const STATUS_NEVER_CONTACTED = 'NEVER_CONTACTED'; +export const STATUS_OFFLINE = 'OFFLINE'; +export const STATUS_STALE = 'STALE'; // CiRunnerAccessLevel diff --git a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql b/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql index 3e5109b1ac4..6da9e276f74 100644 --- a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql +++ b/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql @@ -13,6 +13,7 @@ query getGroupRunners( $sort: CiRunnerSort ) { group(fullPath: $groupFullPath) { + id runners( membership: DESCENDANTS before: $before diff --git a/app/assets/javascripts/runner/graphql/get_runner.query.graphql b/app/assets/javascripts/runner/graphql/get_runner.query.graphql index c294cb9bf22..59c55eae060 100644 --- a/app/assets/javascripts/runner/graphql/get_runner.query.graphql +++ b/app/assets/javascripts/runner/graphql/get_runner.query.graphql @@ -1,6 +1,8 @@ #import "ee_else_ce/runner/graphql/runner_details.fragment.graphql" query getRunner($id: CiRunnerID!) { + # We have an id in deeply nested fragment + # eslint-disable-next-line @graphql-eslint/require-id-when-available runner(id: $id) { ...RunnerDetails } diff --git a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql index 98f2dab26ca..169f6ffd2ea 100644 --- a/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql +++ b/app/assets/javascripts/runner/graphql/runner_node.fragment.graphql @@ -8,7 +8,8 @@ fragment RunnerNode on CiRunner { ipAddress active locked + jobCount tagList contactedAt - status + status(legacyMode: null) } diff --git a/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql b/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql index ea622fd4958..8d1b75828be 100644 --- a/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql +++ b/app/assets/javascripts/runner/graphql/runner_update.mutation.graphql @@ -5,6 +5,8 @@ mutation runnerUpdate($input: RunnerUpdateInput!) { runnerUpdate(input: $input) { + # We have an id in deep nested fragment + # eslint-disable-next-line @graphql-eslint/require-id-when-available runner { ...RunnerDetails } diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/runner/group_runners/group_runners_app.vue index c3dfa885f27..a58a53a6a0d 100644 --- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue @@ -9,6 +9,7 @@ import RegistrationDropdown from '../components/registration/registration_dropdo import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; import RunnerList from '../components/runner_list.vue'; import RunnerName from '../components/runner_name.vue'; +import RunnerOnlineStat from '../components/stat/runner_online_stat.vue'; import RunnerPagination from '../components/runner_pagination.vue'; import RunnerTypeTabs from '../components/runner_type_tabs.vue'; @@ -35,6 +36,7 @@ export default { RunnerFilteredSearchBar, RunnerList, RunnerName, + RunnerOnlineStat, RunnerPagination, RunnerTypeTabs, }, @@ -145,6 +147,8 @@ export default { <template> <div> + <runner-online-stat class="gl-py-6 gl-px-5" :value="groupRunnersCount" /> + <div class="gl-display-flex gl-align-items-center"> <runner-type-tabs v-model="search" @@ -164,11 +168,7 @@ export default { v-model="search" :tokens="searchTokens" :namespace="filteredSearchNamespace" - > - <template #runner-count> - {{ runnerCountMessage }} - </template> - </runner-filtered-search-bar> + /> <div v-if="noRunnersFound" class="gl-text-center gl-p-5"> {{ __('No runners found') }} diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue index bc13150c99c..75d2b324623 100644 --- a/app/assets/javascripts/security_configuration/components/app.vue +++ b/app/assets/javascripts/security_configuration/components/app.vue @@ -1,12 +1,14 @@ <script> import { GlTab, GlTabs, GlSprintf, GlLink, GlAlert } from '@gitlab/ui'; import { __, s__ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; import AutoDevOpsAlert from './auto_dev_ops_alert.vue'; import AutoDevOpsEnabledAlert from './auto_dev_ops_enabled_alert.vue'; import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY } from './constants'; import FeatureCard from './feature_card.vue'; +import TrainingProviderList from './training_provider_list.vue'; import SectionLayout from './section_layout.vue'; import UpgradeBanner from './upgrade_banner.vue'; @@ -23,6 +25,8 @@ export const i18n = { any subsequent feature branch you create will include the scan.`, ), securityConfiguration: __('Security Configuration'), + vulnerabilityManagement: s__('SecurityConfiguration|Vulnerability Management'), + securityTraining: s__('SecurityConfiguration|Security training'), }; export default { @@ -40,7 +44,9 @@ export default { SectionLayout, UpgradeBanner, UserCalloutDismisser, + TrainingProviderList, }, + mixins: [glFeatureFlagsMixin()], inject: ['projectPath'], props: { augmentedSecurityFeatures: { @@ -231,6 +237,17 @@ export default { </template> </section-layout> </gl-tab> + <gl-tab + v-if="glFeatures.secureVulnerabilityTraining" + data-testid="vulnerability-management-tab" + :title="$options.i18n.vulnerabilityManagement" + > + <section-layout :heading="$options.i18n.securityTraining"> + <template #features> + <training-provider-list /> + </template> + </section-layout> + </gl-tab> </gl-tabs> </article> </template> diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index 9c80506549e..dd8ba72ad1f 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -22,6 +22,7 @@ import configureSecretDetectionMutation from '../graphql/configure_secret_detect /** * Translations & helpPagePaths for Security Configuration Page + * Make sure to add new scanner translations to the SCANNER_NAMES_MAP below. */ export const SAST_NAME = __('Static Application Security Testing (SAST)'); @@ -138,6 +139,18 @@ export const LICENSE_COMPLIANCE_HELP_PATH = helpPagePath( 'user/compliance/license_compliance/index', ); +export const SCANNER_NAMES_MAP = { + SAST: SAST_SHORT_NAME, + SAST_IAC: SAST_IAC_NAME, + DAST: DAST_SHORT_NAME, + API_FUZZING: API_FUZZING_NAME, + CONTAINER_SCANNING: CONTAINER_SCANNING_NAME, + CLUSTER_IMAGE_SCANNING: CLUSTER_IMAGE_SCANNING_NAME, + COVERAGE_FUZZING: COVERAGE_FUZZING_NAME, + SECRET_DETECTION: SECRET_DETECTION_NAME, + DEPENDENCY_SCANNING: DEPENDENCY_SCANNING_NAME, +}; + export const securityFeatures = [ { name: SAST_NAME, @@ -156,27 +169,23 @@ export const securityFeatures = [ // https://gitlab.com/gitlab-org/gitlab/-/issues/331621 canEnableByMergeRequest: true, }, - ...(gon?.features?.configureIacScanningViaMr - ? [ - { - name: SAST_IAC_NAME, - shortName: SAST_IAC_SHORT_NAME, - description: SAST_IAC_DESCRIPTION, - helpPath: SAST_IAC_HELP_PATH, - configurationHelpPath: SAST_IAC_CONFIG_HELP_PATH, - type: REPORT_TYPE_SAST_IAC, + { + name: SAST_IAC_NAME, + shortName: SAST_IAC_SHORT_NAME, + description: SAST_IAC_DESCRIPTION, + helpPath: SAST_IAC_HELP_PATH, + configurationHelpPath: SAST_IAC_CONFIG_HELP_PATH, + type: REPORT_TYPE_SAST_IAC, - // This field is currently hardcoded because SAST IaC is always available. - // It will eventually come from the Backend, the progress is tracked in - // https://gitlab.com/gitlab-org/gitlab/-/issues/331622 - available: true, + // This field is currently hardcoded because SAST IaC is always available. + // It will eventually come from the Backend, the progress is tracked in + // https://gitlab.com/gitlab-org/gitlab/-/issues/331622 + available: true, - // This field will eventually come from the backend, the progress is - // tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/331621 - canEnableByMergeRequest: true, - }, - ] - : []), + // This field will eventually come from the backend, the progress is + // tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/331621 + canEnableByMergeRequest: true, + }, { name: DAST_NAME, shortName: DAST_SHORT_NAME, @@ -278,21 +287,17 @@ export const featureToMutationMap = { }, }), }, - ...(gon?.features?.configureIacScanningViaMr - ? { - [REPORT_TYPE_SAST_IAC]: { - mutationId: 'configureSastIac', - getMutationPayload: (projectPath) => ({ - mutation: configureSastIacMutation, - variables: { - input: { - projectPath, - }, - }, - }), + [REPORT_TYPE_SAST_IAC]: { + mutationId: 'configureSastIac', + getMutationPayload: (projectPath) => ({ + mutation: configureSastIacMutation, + variables: { + input: { + projectPath, }, - } - : {}), + }, + }), + }, [REPORT_TYPE_SECRET_DETECTION]: { mutationId: 'configureSecretDetection', getMutationPayload: (projectPath) => ({ diff --git a/app/assets/javascripts/security_configuration/components/training_provider_list.vue b/app/assets/javascripts/security_configuration/components/training_provider_list.vue new file mode 100644 index 00000000000..509377a63e8 --- /dev/null +++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue @@ -0,0 +1,61 @@ +<script> +import { GlCard, GlToggle, GlLink, GlSkeletonLoader } from '@gitlab/ui'; +import securityTrainingProvidersQuery from '../graphql/security_training_providers.query.graphql'; + +export default { + components: { + GlCard, + GlToggle, + GlLink, + GlSkeletonLoader, + }, + apollo: { + securityTrainingProviders: { + query: securityTrainingProvidersQuery, + }, + }, + data() { + return { + securityTrainingProviders: [], + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.securityTrainingProviders.loading; + }, + }, +}; +</script> + +<template> + <div + v-if="isLoading" + class="gl-bg-white gl-py-6 gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100" + > + <gl-skeleton-loader :width="350" :height="44"> + <rect width="200" height="8" x="10" y="0" rx="4" /> + <rect width="300" height="8" x="10" y="15" rx="4" /> + <rect width="100" height="8" x="10" y="35" rx="4" /> + </gl-skeleton-loader> + </div> + <ul v-else class="gl-list-style-none gl-m-0 gl-p-0"> + <li + v-for="{ id, isEnabled, name, description, url } in securityTrainingProviders" + :key="id" + class="gl-mb-6" + > + <gl-card> + <div class="gl-display-flex"> + <gl-toggle :value="isEnabled" :label="__('Training mode')" label-position="hidden" /> + <div class="gl-ml-5"> + <h3 class="gl-font-lg gl-m-0 gl-mb-2">{{ name }}</h3> + <p> + {{ description }} + <gl-link :href="url" target="_blank">{{ __('Learn more.') }}</gl-link> + </p> + </div> + </div> + </gl-card> + </li> + </ul> +</template> diff --git a/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql b/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql new file mode 100644 index 00000000000..e0c5715ba8e --- /dev/null +++ b/app/assets/javascripts/security_configuration/graphql/security_training_providers.query.graphql @@ -0,0 +1,9 @@ +query Query { + securityTrainingProviders @client { + name + id + description + isEnabled + url + } +} diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js index a8623b468f2..c86ff1a58f2 100644 --- a/app/assets/javascripts/security_configuration/index.js +++ b/app/assets/javascripts/security_configuration/index.js @@ -2,10 +2,39 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import { parseBooleanDataAttributes } from '~/lib/utils/dom_utils'; +import { __ } from '~/locale'; import SecurityConfigurationApp from './components/app.vue'; import { securityFeatures, complianceFeatures } from './components/constants'; import { augmentFeatures } from './utils'; +// Note: this is behind a feature flag and only a placeholder +// until the actual GraphQL fields have been added +// https://gitlab.com/gitlab-org/gi tlab/-/issues/346480 +export const tempResolvers = { + Query: { + securityTrainingProviders() { + return [ + { + __typename: 'SecurityTrainingProvider', + id: 101, + name: __('Kontra'), + description: __('Interactive developer security education.'), + url: 'https://application.security/', + isEnabled: false, + }, + { + __typename: 'SecurityTrainingProvider', + id: 102, + name: __('SecureCodeWarrior'), + description: __('Security training with guide and learning pathways.'), + url: 'https://www.securecodewarrior.com/', + isEnabled: true, + }, + ]; + }, + }, +}; + export const initSecurityConfiguration = (el) => { if (!el) { return null; @@ -14,7 +43,7 @@ export const initSecurityConfiguration = (el) => { Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient(tempResolvers), }); const { diff --git a/app/assets/javascripts/security_configuration/utils.js b/app/assets/javascripts/security_configuration/utils.js index ec6b93c6193..47231497b8f 100644 --- a/app/assets/javascripts/security_configuration/utils.js +++ b/app/assets/javascripts/security_configuration/utils.js @@ -1,4 +1,5 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { SCANNER_NAMES_MAP } from '~/security_configuration/components/constants'; export const augmentFeatures = (securityFeatures, complianceFeatures, features = []) => { const featuresByType = features.reduce((acc, feature) => { @@ -24,3 +25,13 @@ export const augmentFeatures = (securityFeatures, complianceFeatures, features = augmentedComplianceFeatures: complianceFeatures.map((feature) => augmentFeature(feature)), }; }; + +/** + * Converts a list of security scanner IDs (such as SAST_IAC) into a list of their translated + * names defined in the SCANNER_NAMES_MAP constant (eg. IaC Scanning). + * + * @param {String[]} scannerNames + * @returns {String[]} + */ +export const translateScannerNames = (scannerNames = []) => + scannerNames.map((scannerName) => SCANNER_NAMES_MAP[scannerName] || scannerName); diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue index 0021fe909e5..e41f3aa5c9d 100644 --- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue +++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue @@ -236,6 +236,8 @@ export default { }, statusTimeRanges, safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, + actionPrimary: { text: s__('SetStatusModal|Set status') }, + actionSecondary: { text: s__('SetStatusModal|Remove status') }, }; </script> @@ -243,14 +245,13 @@ export default { <gl-modal :title="s__('SetStatusModal|Set a status')" :modal-id="modalId" - :ok-title="s__('SetStatusModal|Set status')" - :cancel-title="s__('SetStatusModal|Remove status')" - ok-variant="success" + :action-primary="$options.actionPrimary" + :action-secondary="$options.actionSecondary" modal-class="set-user-status-modal" @shown="setupEmojiListAndAutocomplete" @hide="hideEmojiMenu" - @ok="setStatus" - @cancel="removeStatus" + @primary="setStatus" + @secondary="removeStatus" > <div> <input diff --git a/app/assets/javascripts/shared/milestones/form.js b/app/assets/javascripts/shared/milestones/form.js deleted file mode 100644 index 3ca9288b156..00000000000 --- a/app/assets/javascripts/shared/milestones/form.js +++ /dev/null @@ -1,22 +0,0 @@ -import $ from 'jquery'; -import initDatePicker from '~/behaviors/date_picker'; -import GLForm from '../../gl_form'; -import ZenMode from '../../zen_mode'; - -export default (initGFM = true) => { - new ZenMode(); // eslint-disable-line no-new - initDatePicker(); - - // eslint-disable-next-line no-new - new GLForm($('.milestone-form'), { - emojis: true, - members: initGFM, - issues: initGFM, - mergeRequests: initGFM, - epics: initGFM, - milestones: initGFM, - labels: initGFM, - snippets: initGFM, - vulnerabilities: initGFM, - }); -}; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue index e7ef731eed8..2387fe64b8f 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue @@ -1,7 +1,7 @@ <script> import produce from 'immer'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { assigneesQueries } from '~/sidebar/constants'; export default { diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue index 20667e695ce..6a74ab83c22 100644 --- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue @@ -110,7 +110,7 @@ export default { <template> <div v-gl-tooltip="tooltipOptions" - :class="{ 'multiple-users': hasMoreThanOneAssignee }" + :class="{ 'multiple-users gl-relative': hasMoreThanOneAssignee }" :title="tooltipTitle" class="sidebar-collapsed-icon sidebar-collapsed-user" > diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue index 1b28ba2afd1..5b4dc20e9c8 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -3,7 +3,7 @@ import { GlDropdownItem } from '@gitlab/ui'; import { cloneDeep } from 'lodash'; import Vue from 'vue'; import createFlash from '~/flash'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { __, n__ } from '~/locale'; import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue index 8d5c3b2def3..a27dbee31ec 100644 --- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue @@ -1,6 +1,6 @@ <script> import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { __, sprintf } from '~/locale'; import AttentionRequestedToggle from '../attention_requested_toggle.vue'; import AssigneeAvatarLink from './assignee_avatar_link.vue'; diff --git a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue index 38ba468d197..42e56906e2c 100644 --- a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue +++ b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue @@ -64,7 +64,7 @@ export default { <gl-button :loading="loading" :variant="user.attention_requested ? 'warning' : 'default'" - :icon="user.attention_requested ? 'star' : 'star-o'" + :icon="user.attention_requested ? 'attention-solid' : 'attention'" :aria-label="tooltipTitle" size="small" category="tertiary" diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue index 1fb4bd26533..209d1cca360 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue @@ -1,7 +1,7 @@ <script> import { GlSprintf, GlButton } from '@gitlab/ui'; import createFlash from '~/flash'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { __, sprintf } from '~/locale'; import { confidentialityQueries } from '~/sidebar/constants'; diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue new file mode 100644 index 00000000000..6d4da104952 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue @@ -0,0 +1,131 @@ +<script> +import { GlIcon, GlPopover, GlTooltipDirective } from '@gitlab/ui'; +import { __, n__, sprintf } from '~/locale'; +import createFlash from '~/flash'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_ISSUE } from '~/graphql_shared/constants'; +import getIssueCrmContactsQuery from './queries/get_issue_crm_contacts.query.graphql'; +import issueCrmContactsSubscription from './queries/issue_crm_contacts.subscription.graphql'; + +export default { + components: { + GlIcon, + GlPopover, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + issueId: { + type: String, + required: true, + }, + }, + data() { + return { + contacts: [], + }; + }, + apollo: { + contacts: { + query: getIssueCrmContactsQuery, + variables() { + return this.queryVariables; + }, + update(data) { + return data?.issue?.customerRelationsContacts?.nodes; + }, + error(error) { + createFlash({ + message: __('Something went wrong trying to load issue contacts.'), + error, + captureError: true, + }); + }, + subscribeToMore: { + document: issueCrmContactsSubscription, + variables() { + return this.queryVariables; + }, + updateQuery(prev, { subscriptionData }) { + const draftData = subscriptionData?.data?.issueCrmContactsUpdated; + if (prev && draftData) return { issue: draftData }; + return prev; + }, + }, + }, + }, + computed: { + shouldShowContacts() { + return this.contacts?.length; + }, + queryVariables() { + return { id: convertToGraphQLId(TYPE_ISSUE, this.issueId) }; + }, + contactsLabel() { + return sprintf(n__('%{count} contact', '%{count} contacts', this.contactCount), { + count: this.contactCount, + }); + }, + contactCount() { + return this.contacts?.length || 0; + }, + }, + methods: { + shouldShowPopover(contact) { + return this.popOverData(contact).length > 0; + }, + divider(index) { + if (index < this.contactCount - 1) return ','; + return ''; + }, + popOverData(contact) { + return [contact.organization?.name, contact.email, contact.phone, contact.description].filter( + Boolean, + ); + }, + }, + i18n: { + help: __('Work in progress- click here to find out more'), + }, +}; +</script> + +<template> + <div> + <div v-gl-tooltip.left.viewport :title="contactsLabel" class="sidebar-collapsed-icon"> + <gl-icon name="users" /> + <span> {{ contactCount }} </span> + </div> + <div + v-gl-tooltip.left.viewport="$options.i18n.help" + class="hide-collapsed help-button float-right" + > + <a href="https://gitlab.com/gitlab-org/gitlab/-/issues/2256"><gl-icon name="question-o" /></a> + </div> + <div class="title hide-collapsed gl-mb-2 gl-line-height-20"> + {{ contactsLabel }} + </div> + <div class="hide-collapsed gl-display-flex gl-flex-wrap"> + <div + v-for="(contact, index) in contacts" + :id="`contact_container_${index}`" + :key="index" + class="gl-pr-2" + > + <span :id="`contact_${index}`" class="gl-font-weight-bold" + >{{ contact.firstName }} {{ contact.lastName }}{{ divider(index) }}</span + > + <gl-popover + v-if="shouldShowPopover(contact)" + :target="`contact_${index}`" + :container="`contact_container_${index}`" + triggers="hover focus" + placement="top" + > + <div v-for="row in popOverData(contact)" :key="row">{{ row }}</div> + </gl-popover> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql b/app/assets/javascripts/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql new file mode 100644 index 00000000000..30a0af10d56 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql @@ -0,0 +1,7 @@ +#import "./issue_crm_contacts.fragment.graphql" + +query issueCrmContacts($id: IssueID!) { + issue(id: $id) { + ...CrmContacts + } +} diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.fragment.graphql b/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.fragment.graphql new file mode 100644 index 00000000000..750e1f1d1af --- /dev/null +++ b/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.fragment.graphql @@ -0,0 +1,17 @@ +fragment CrmContacts on Issue { + id + customerRelationsContacts { + nodes { + id + firstName + lastName + email + phone + description + organization { + id + name + } + } + } +} diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql b/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql new file mode 100644 index 00000000000..f3b6e4ec06f --- /dev/null +++ b/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql @@ -0,0 +1,9 @@ +#import "./issue_crm_contacts.fragment.graphql" + +subscription issueCrmContactsUpdated($id: IssuableID!) { + issueCrmContactsUpdated(issuableId: $id) { + ... on Issue { + ...CrmContacts + } + } +} diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue index 1ff24dec884..404bcc3122a 100644 --- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue +++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlDatepicker, GlTooltipDirective, GlLink, GlPopover } from '@gitlab/ui'; import createFlash from '~/flash'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility'; import { __, sprintf } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; @@ -124,6 +124,9 @@ export default { isLoading() { return this.$apollo.queries.issuable.loading || this.loading; }, + initialLoading() { + return this.$apollo.queries.issuable.loading; + }, hasDate() { return this.dateValue !== null; }, @@ -151,7 +154,7 @@ export default { }; }, dataTestId() { - return this.dateType === dateTypes.start ? 'start-date' : 'due-date'; + return this.dateType === dateTypes.start ? 'sidebar-start-date' : 'sidebar-due-date'; }, }, methods: { @@ -266,15 +269,15 @@ export default { </gl-popover> </template> <template #collapsed> - <div v-gl-tooltip :title="dateLabel" class="sidebar-collapsed-icon"> + <div v-gl-tooltip.viewport.left :title="dateLabel" class="sidebar-collapsed-icon"> <gl-icon :size="16" name="calendar" /> <span class="collapse-truncated-title">{{ formattedDate }}</span> </div> <sidebar-inherit-date - v-if="canInherit" + v-if="canInherit && !initialLoading" :issuable="issuable" - :is-loading="isLoading" :date-type="dateType" + :is-loading="isLoading" @reset-date="setDate(null)" @set-date="setFixedDate" /> diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue b/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue index b6bfacb2e47..77f8e125dce 100644 --- a/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue +++ b/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue @@ -17,8 +17,9 @@ export default { type: Object, }, isLoading: { - required: true, + required: false, type: Boolean, + default: false, }, dateType: { type: String, @@ -31,6 +32,7 @@ export default { return this.issuable?.[dateFields[this.dateType].isDateFixed] || false; }, set(fixed) { + if (fixed === this.issuable[dateFields[this.dateType].isDateFixed]) return; this.$emit('set-date', fixed); }, }, diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue deleted file mode 100644 index 5cd4a1a5192..00000000000 --- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue +++ /dev/null @@ -1,192 +0,0 @@ -<script> -import $ from 'jquery'; -import { camelCase, difference, union } from 'lodash'; -import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; -import createFlash from '~/flash'; -import { getIdFromGraphQLId, MutationOperationMode } from '~/graphql_shared/utils'; -import { IssuableType } from '~/issue_show/constants'; -import { __ } from '~/locale'; -import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql'; -import { toLabelGid } from '~/sidebar/utils'; -import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; -import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; -import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; -import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; - -const mutationMap = { - [IssuableType.Issue]: { - mutation: updateIssueLabelsMutation, - mutationName: 'updateIssue', - }, - [IssuableType.MergeRequest]: { - mutation: updateMergeRequestLabelsMutation, - mutationName: 'mergeRequestSetLabels', - }, -}; - -export default { - components: { - LabelsSelect, - LabelsSelectWidget, - }, - variant: DropdownVariant.Sidebar, - mixins: [glFeatureFlagMixin()], - inject: [ - 'allowLabelCreate', - 'allowLabelEdit', - 'allowScopedLabels', - 'iid', - 'fullPath', - 'initiallySelectedLabels', - 'issuableType', - 'labelsFetchPath', - 'labelsManagePath', - 'projectIssuesPath', - 'projectPath', - ], - data() { - return { - isLabelsSelectInProgress: false, - selectedLabels: this.initiallySelectedLabels, - LabelType, - }; - }, - methods: { - handleDropdownClose() { - $(this.$el).trigger('hidden.gl.dropdown'); - }, - getUpdateVariables(labels) { - let labelIds = []; - - if (this.glFeatures.labelsWidget) { - labelIds = labels.map(({ id }) => toLabelGid(id)); - } else { - const currentLabelIds = this.selectedLabels.map((label) => label.id); - const userAddedLabelIds = labels.filter((label) => label.set).map((label) => label.id); - const userRemovedLabelIds = labels.filter((label) => !label.set).map((label) => label.id); - - labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds).map( - toLabelGid, - ); - } - - switch (this.issuableType) { - case IssuableType.Issue: - return { - iid: this.iid, - projectPath: this.projectPath, - labelIds, - }; - case IssuableType.MergeRequest: - return { - iid: this.iid, - labelIds, - operationMode: MutationOperationMode.Replace, - projectPath: this.projectPath, - }; - default: - return {}; - } - }, - handleUpdateSelectedLabels(dropdownLabels) { - this.updateSelectedLabels(this.getUpdateVariables(dropdownLabels)); - }, - getRemoveVariables(labelId) { - switch (this.issuableType) { - case IssuableType.Issue: - return { - iid: this.iid, - projectPath: this.projectPath, - removeLabelIds: [labelId], - }; - case IssuableType.MergeRequest: - return { - iid: this.iid, - labelIds: [toLabelGid(labelId)], - operationMode: MutationOperationMode.Remove, - projectPath: this.projectPath, - }; - default: - return {}; - } - }, - handleLabelRemove(labelId) { - this.updateSelectedLabels(this.getRemoveVariables(labelId)); - }, - updateSelectedLabels(inputVariables) { - this.isLabelsSelectInProgress = true; - - this.$apollo - .mutate({ - mutation: mutationMap[this.issuableType].mutation, - variables: { input: inputVariables }, - }) - .then(({ data }) => { - const { mutationName } = mutationMap[this.issuableType]; - - if (data[mutationName]?.errors?.length) { - throw new Error(); - } - - const issuableType = camelCase(this.issuableType); - this.selectedLabels = data[mutationName]?.[issuableType]?.labels?.nodes?.map((label) => ({ - ...label, - id: getIdFromGraphQLId(label.id), - })); - }) - .catch(() => createFlash({ message: __('An error occurred while updating labels.') })) - .finally(() => { - this.isLabelsSelectInProgress = false; - }); - }, - }, -}; -</script> - -<template> - <labels-select-widget - v-if="glFeatures.labelsWidget" - class="block labels js-labels-block" - :iid="iid" - :full-path="fullPath" - :allow-label-remove="allowLabelEdit" - :allow-multiselect="true" - :footer-create-label-title="__('Create project label')" - :footer-manage-label-title="__('Manage project labels')" - :labels-create-title="__('Create project label')" - :labels-filter-base-path="projectIssuesPath" - :variant="$options.variant" - :issuable-type="issuableType" - workspace-type="project" - :attr-workspace-path="fullPath" - :label-create-type="LabelType.project" - data-qa-selector="labels_block" - > - {{ __('None') }} - </labels-select-widget> - <labels-select - v-else - class="block labels js-labels-block" - :allow-label-remove="allowLabelEdit" - :allow-label-create="allowLabelCreate" - :allow-label-edit="allowLabelEdit" - :allow-multiselect="true" - :allow-scoped-labels="allowScopedLabels" - :footer-create-label-title="__('Create project label')" - :footer-manage-label-title="__('Manage project labels')" - :labels-create-title="__('Create project label')" - :labels-fetch-path="labelsFetchPath" - :labels-filter-base-path="projectIssuesPath" - :labels-manage-path="labelsManagePath" - :labels-select-in-progress="isLabelsSelectInProgress" - :selected-labels="selectedLabels" - :variant="$options.sidebar" - data-qa-selector="labels_block" - @onDropdownClose="handleDropdownClose" - @onLabelRemove="handleLabelRemove" - @updateSelectedLabels="handleUpdateSelectedLabels" - > - {{ __('None') }} - </labels-select> -</template> diff --git a/app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql b/app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql index 2a1bcdf7136..cb9ee6abc9b 100644 --- a/app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql +++ b/app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql @@ -1,6 +1,7 @@ mutation updateIssueLocked($input: IssueSetLockedInput!) { issueSetLocked(input: $input) { issue { + id discussionLocked } errors diff --git a/app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql b/app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql index 8590c8e71a6..11eb3611006 100644 --- a/app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql +++ b/app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql @@ -1,6 +1,7 @@ mutation updateMergeRequestLocked($input: MergeRequestSetLockedInput!) { mergeRequestSetLocked(input: $input) { mergeRequest { + id discussionLocked } errors diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue index 9554a98121f..60d8fb4d408 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue @@ -89,7 +89,7 @@ export default { <template> <div v-gl-tooltip="tooltipOptions" - :class="{ 'multiple-users': hasMoreThanOneReviewer }" + :class="{ 'multiple-users gl-relative': hasMoreThanOneReviewer }" :title="tooltipTitle" class="sidebar-collapsed-icon sidebar-collapsed-user" > diff --git a/app/assets/javascripts/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql b/app/assets/javascripts/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql index 750e757971f..c9d36dfdb67 100644 --- a/app/assets/javascripts/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql +++ b/app/assets/javascripts/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql @@ -3,6 +3,7 @@ mutation updateIssuableSeverity($projectPath: ID!, $severity: IssuableSeverity!, errors issue { iid + id severity } } diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue index 5dc93476120..86e46016534 100644 --- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue +++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue @@ -5,7 +5,7 @@ import { GlLoadingIcon, GlTooltip, GlSprintf, - GlLink, + GlButton, } from '@gitlab/ui'; import createFlash from '~/flash'; import { INCIDENT_SEVERITY, ISSUABLE_TYPES, I18N } from './constants'; @@ -20,7 +20,7 @@ export default { GlSprintf, GlDropdown, GlDropdownItem, - GlLink, + GlButton, SeverityToken, }, inject: ['canUpdate'], @@ -150,23 +150,25 @@ export default { <div class="hide-collapsed"> <p - class="gl-line-height-20 gl-mb-0 gl-text-gray-900 gl-display-flex gl-justify-content-space-between" + class="gl-line-height-20 gl-mb-2 gl-text-gray-900 gl-display-flex gl-justify-content-space-between" > {{ $options.i18n.SEVERITY }} - <gl-link + <gl-button v-if="canUpdate" + category="tertiary" + size="small" data-testid="editButton" - href="#" @click="toggleFormDropdown" @keydown.esc="hideDropdown" > {{ $options.i18n.EDIT }} - </gl-link> + </gl-button> </p> <gl-dropdown :class="dropdownClass" block + :header-text="__('Assign severity')" :text="selectedItem.label" toggle-class="dropdown-menu-toggle gl-mb-2" @keydown.esc.native="hideDropdown" diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue index 0ba8c4f8907..da792b3a2aa 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -12,11 +12,12 @@ import { } from '@gitlab/ui'; import createFlash from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { timeFor } from '~/lib/utils/datetime_utility'; -import { __, s__, sprintf } from '~/locale'; +import { __ } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import { + dropdowni18nText, Tracking, IssuableAttributeState, IssuableAttributeType, @@ -24,14 +25,11 @@ import { noAttributeId, defaultEpicSort, epicIidPattern, -} from '~/sidebar/constants'; +} from 'ee_else_ce/sidebar/constants'; export default { noAttributeId, - IssuableAttributeState, - issuableAttributesQueries, i18n: { - [IssuableAttributeType.Milestone]: __('Milestone'), expired: __('(expired)'), none: __('None'), }, @@ -53,14 +51,24 @@ export default { isClassicSidebar: { default: false, }, + issuableAttributesQueries: { + default: issuableAttributesQueries, + }, + issuableAttributesState: { + default: IssuableAttributeState, + }, + widgetTitleText: { + default: { + [IssuableAttributeType.Milestone]: __('Milestone'), + expired: __('(expired)'), + none: __('None'), + }, + }, }, props: { issuableAttribute: { type: String, required: true, - validator(value) { - return [IssuableAttributeType.Milestone].includes(value); - }, }, workspacePath: { required: true, @@ -132,13 +140,13 @@ export default { return { fullPath: this.attrWorkspacePath, title: this.searchTerm, - state: this.$options.IssuableAttributeState[this.issuableAttribute], + state: this.issuableAttributesState[this.issuableAttribute], }; } const variables = { fullPath: this.attrWorkspacePath, - state: this.$options.IssuableAttributeState[this.issuableAttribute], + state: this.issuableAttributesState[this.issuableAttribute], sort: defaultEpicSort, }; @@ -180,7 +188,7 @@ export default { }, computed: { issuableAttributeQuery() { - return this.$options.issuableAttributesQueries[this.issuableAttribute]; + return this.issuableAttributesQueries[this.issuableAttribute]; }, attributeTitle() { return this.currentAttribute?.title || this.i18n.noAttribute; @@ -189,9 +197,7 @@ export default { return this.currentAttribute?.webUrl; }, dropdownText() { - return this.currentAttribute - ? this.currentAttribute?.title - : this.$options.i18n[this.issuableAttribute]; + return this.currentAttribute ? this.currentAttribute?.title : this.attributeTypeTitle; }, loading() { return this.$apollo.queries.currentAttribute.loading; @@ -200,7 +206,7 @@ export default { return this.attributesList.length === 0; }, attributeTypeTitle() { - return this.$options.i18n[this.issuableAttribute]; + return this.widgetTitleText[this.issuableAttribute]; }, attributeTypeIcon() { return this.icon || this.issuableAttribute; @@ -209,37 +215,10 @@ export default { return timeFor(this.currentAttribute?.dueDate); }, i18n() { - return { - noAttribute: sprintf(s__('DropdownWidget|No %{issuableAttribute}'), { - issuableAttribute: this.issuableAttribute, - }), - assignAttribute: sprintf(s__('DropdownWidget|Assign %{issuableAttribute}'), { - issuableAttribute: this.issuableAttribute, - }), - noAttributesFound: sprintf(s__('DropdownWidget|No %{issuableAttribute} found'), { - issuableAttribute: this.issuableAttribute, - }), - updateError: sprintf( - s__( - 'DropdownWidget|Failed to set %{issuableAttribute} on this %{issuableType}. Please try again.', - ), - { issuableAttribute: this.issuableAttribute, issuableType: this.issuableType }, - ), - listFetchError: sprintf( - s__( - 'DropdownWidget|Failed to fetch the %{issuableAttribute} for this %{issuableType}. Please try again.', - ), - { issuableAttribute: this.issuableAttribute, issuableType: this.issuableType }, - ), - currentFetchError: sprintf( - s__( - 'DropdownWidget|An error occurred while fetching the assigned %{issuableAttribute} of the selected %{issuableType}.', - ), - { issuableAttribute: this.issuableAttribute, issuableType: this.issuableType }, - ), - }; + return dropdowni18nText(this.issuableAttribute, this.issuableType); }, isEpic() { + // MV to EE https://gitlab.com/gitlab-org/gitlab/-/issues/345311 return this.issuableAttribute === IssuableType.Epic; }, }, @@ -252,7 +231,7 @@ export default { const selectedAttribute = Boolean(attributeId) && this.attributesList.find((p) => p.id === attributeId); - this.selectedTitle = selectedAttribute ? selectedAttribute.title : this.$options.i18n.none; + this.selectedTitle = selectedAttribute ? selectedAttribute.title : this.widgetTitleText.none; const { current } = this.issuableAttributeQuery; const { mutation } = current[this.issuableType]; diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue index bc7e377a966..701833c4e95 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlLoadingIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui'; import createFlash from '~/flash'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { __, sprintf } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index 9a9d03353dc..91c67a03dfb 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -1,6 +1,6 @@ <script> import { GlIcon, GlLink, GlModal, GlModalDirective, GlLoadingIcon } from '@gitlab/ui'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { s__, __ } from '~/locale'; import { timeTrackingQueries } from '~/sidebar/constants'; diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index ac34a75ac5c..0238fb8e8d5 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -1,5 +1,6 @@ +import { s__, sprintf } from '~/locale'; import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; -import { IssuableType, WorkspaceType } from '~/issue_show/constants'; +import { IssuableType, WorkspaceType } from '~/issues/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql'; import epicDueDateQuery from '~/sidebar/queries/epic_due_date.query.graphql'; @@ -272,3 +273,35 @@ export const todoMutations = { [TodoMutationTypes.Create]: todoCreateMutation, [TodoMutationTypes.MarkDone]: todoMarkDoneMutation, }; + +export function dropdowni18nText(issuableAttribute, issuableType) { + return { + noAttribute: sprintf(s__('DropdownWidget|No %{issuableAttribute}'), { + issuableAttribute, + }), + assignAttribute: sprintf(s__('DropdownWidget|Assign %{issuableAttribute}'), { + issuableAttribute, + }), + noAttributesFound: sprintf(s__('DropdownWidget|No %{issuableAttribute} found'), { + issuableAttribute, + }), + updateError: sprintf( + s__( + 'DropdownWidget|Failed to set %{issuableAttribute} on this %{issuableType}. Please try again.', + ), + { issuableAttribute, issuableType }, + ), + listFetchError: sprintf( + s__( + 'DropdownWidget|Failed to fetch the %{issuableAttribute} for this %{issuableType}. Please try again.', + ), + { issuableAttribute, issuableType }, + ), + currentFetchError: sprintf( + s__( + 'DropdownWidget|An error occurred while fetching the assigned %{issuableAttribute} of the selected %{issuableType}.', + ), + { issuableAttribute, issuableType }, + ), + }; +} diff --git a/app/assets/javascripts/sidebar/graphql.js b/app/assets/javascripts/sidebar/graphql.js index 6a670db2d38..5b2ce3fe446 100644 --- a/app/assets/javascripts/sidebar/graphql.js +++ b/app/assets/javascripts/sidebar/graphql.js @@ -1,7 +1,7 @@ import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import produce from 'immer'; import VueApollo from 'vue-apollo'; -import getIssueStateQuery from '~/issue_show/queries/get_issue_state.query.graphql'; +import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql'; import createDefaultClient from '~/lib/graphql'; import introspectionQueryResultData from './fragmentTypes.json'; diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js index 270b22fcdf9..1947c4801db 100644 --- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { parseBoolean } from '~/lib/utils/common_utils'; import timeTracker from './components/time_tracking/time_tracker.vue'; diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 898be4a97ce..cbe40d0bfbe 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -5,13 +5,14 @@ import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { isInIssuePage, isInDesignPage, isInIncidentPage, parseBoolean, } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; import CollapsedAssigneeList from '~/sidebar/components/assignees/collapsed_assignee_list.vue'; import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; @@ -23,10 +24,11 @@ import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_wid import { apolloProvider } from '~/sidebar/graphql'; import trackShowInviteMemberLink from '~/sidebar/track_invite_members'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; +import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; +import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import Translate from '../vue_shared/translate'; import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue'; -import SidebarLabels from './components/labels/sidebar_labels.vue'; import IssuableLockForm from './components/lock/issuable_lock_form.vue'; import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue'; import SidebarSeverity from './components/severity/sidebar_severity.vue'; @@ -34,6 +36,7 @@ import SidebarSubscriptionsWidget from './components/subscriptions/sidebar_subsc import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue'; import { IssuableAttributeType } from './constants'; import SidebarMoveIssue from './lib/sidebar_move_issue'; +import CrmContacts from './components/crm_contacts/crm_contacts.vue'; Vue.use(Translate); Vue.use(VueApollo); @@ -205,6 +208,28 @@ function mountReviewersComponent(mediator) { } } +function mountCrmContactsComponent() { + const el = document.getElementById('js-issue-crm-contacts'); + + if (!el) return; + + const { issueId } = el.dataset; + // eslint-disable-next-line no-new + new Vue({ + el, + apolloProvider, + components: { + CrmContacts, + }, + render: (createElement) => + createElement('crm-contacts', { + props: { + issueId, + }, + }), + }); +} + function mountMilestoneSelect() { const el = document.querySelector('.js-milestone-select'); @@ -241,7 +266,6 @@ function mountMilestoneSelect() { export function mountSidebarLabels() { const el = document.querySelector('.js-sidebar-labels'); - const { fullPath } = getSidebarOptions(); if (!el) { return false; @@ -250,22 +274,43 @@ export function mountSidebarLabels() { return new Vue({ el, apolloProvider, + + components: { + LabelsSelectWidget, + }, provide: { ...el.dataset, - fullPath, + canUpdate: parseBoolean(el.dataset.canEdit), allowLabelCreate: parseBoolean(el.dataset.allowLabelCreate), allowLabelEdit: parseBoolean(el.dataset.canEdit), allowScopedLabels: parseBoolean(el.dataset.allowScopedLabels), - initiallySelectedLabels: JSON.parse(el.dataset.selectedLabels), - variant: DropdownVariant.Sidebar, - canUpdate: parseBoolean(el.dataset.canEdit), isClassicSidebar: true, - issuableType: - isInIssuePage() || isInIncidentPage() || isInDesignPage() - ? IssuableType.Issue - : IssuableType.MergeRequest, }, - render: (createElement) => createElement(SidebarLabels), + render: (createElement) => + createElement('labels-select-widget', { + props: { + iid: String(el.dataset.iid), + fullPath: el.dataset.projectPath, + allowLabelRemove: parseBoolean(el.dataset.canEdit), + allowMultiselect: true, + footerCreateLabelTitle: __('Create project label'), + footerManageLabelTitle: __('Manage project labels'), + labelsCreateTitle: __('Create project label'), + labelsFilterBasePath: el.dataset.projectIssuesPath, + variant: DropdownVariant.Sidebar, + issuableType: + isInIssuePage() || isInIncidentPage() || isInDesignPage() + ? IssuableType.Issue + : IssuableType.MergeRequest, + workspaceType: 'project', + attrWorkspacePath: el.dataset.projectPath, + labelCreateType: LabelType.project, + }, + class: ['block labels js-labels-block'], + scopedSlots: { + default: () => __('None'), + }, + }), }); } @@ -535,6 +580,7 @@ export function mountSidebar(mediator, store) { mountAssigneesComponentDeprecated(mediator); } mountReviewersComponent(mediator); + mountCrmContactsComponent(); mountSidebarLabels(); mountMilestoneSelect(); mountConfidentialComponent(mediator); diff --git a/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql b/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql index 7a1fdb40e93..4998b2af666 100644 --- a/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql +++ b/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql @@ -1,6 +1,7 @@ query epicConfidential($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { __typename + id issuable: epic(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql b/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql index f60f44abebd..00529042e92 100644 --- a/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql +++ b/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql @@ -1,6 +1,7 @@ query epicDueDate($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { __typename + id issuable: epic(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql b/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql index fbebc50ab08..dada7ffc034 100644 --- a/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql +++ b/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql @@ -4,6 +4,7 @@ query epicParticipants($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { __typename + id issuable: epic(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql b/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql index bd10f09aed8..f35ca896ef8 100644 --- a/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql +++ b/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql @@ -1,6 +1,7 @@ query epicReference($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { __typename + id issuable: epic(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql b/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql index c6c24fd3d95..85fc7de8d02 100644 --- a/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql +++ b/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql @@ -1,6 +1,7 @@ query epicStartDate($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { __typename + id issuable: epic(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql index 9f1967e1685..a8fe6b8ddc3 100644 --- a/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql +++ b/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql @@ -1,6 +1,7 @@ query epicSubscribed($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { __typename + id emailsDisabled issuable: epic(iid: $iid) { __typename diff --git a/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql b/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql index 1e6f9bad5b2..b0ba724e727 100644 --- a/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql +++ b/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql @@ -1,6 +1,7 @@ query epicTodos($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { __typename + id issuable: epic(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql b/app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql index 47ce094418c..a58a04d87c4 100644 --- a/app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql +++ b/app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql @@ -3,6 +3,7 @@ subscription issuableAssigneesUpdated($issuableId: IssuableID!) { issuableAssigneesUpdated(issuableId: $issuableId) { ... on Issue { + id assignees { nodes { ...User diff --git a/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql b/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql index 92cabf46af7..e578cf3bda5 100644 --- a/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql +++ b/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql @@ -1,6 +1,7 @@ query issueConfidential($fullPath: ID!, $iid: String) { workspace: project(fullPath: $fullPath) { __typename + id issuable: issue(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql b/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql index 6d3f782bd0a..48cbff252b3 100644 --- a/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql +++ b/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql @@ -1,6 +1,7 @@ query issueDueDate($fullPath: ID!, $iid: String) { workspace: project(fullPath: $fullPath) { __typename + id issuable: issue(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql b/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql index db4f58a4f69..c3128d6d961 100644 --- a/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql +++ b/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql @@ -1,5 +1,6 @@ query issueReference($fullPath: ID!, $iid: String) { workspace: project(fullPath: $fullPath) { + id __typename issuable: issue(iid: $iid) { __typename diff --git a/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql index 7d38b5d3bd8..e2722fc86a4 100644 --- a/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql +++ b/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql @@ -1,6 +1,7 @@ query issueSubscribed($fullPath: ID!, $iid: String) { workspace: project(fullPath: $fullPath) { __typename + id issuable: issue(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql b/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql index 7ac989b5c63..059361dd370 100644 --- a/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql +++ b/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql @@ -1,6 +1,7 @@ query issueTimeTracking($fullPath: ID!, $iid: String) { workspace: project(fullPath: $fullPath) { __typename + id issuable: issue(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql b/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql index 783d36352fe..5cd5d81c439 100644 --- a/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql +++ b/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql @@ -1,6 +1,7 @@ query issueTodos($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { __typename + id issuable: issue(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql index 5c0edf5acee..b0a16677cf2 100644 --- a/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql +++ b/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql @@ -3,6 +3,7 @@ query mergeRequestMilestone($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { __typename + id issuable: mergeRequest(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql index 7979a1ccb3e..7c78f812b67 100644 --- a/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql +++ b/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql @@ -1,6 +1,7 @@ query mergeRequestReference($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { __typename + id issuable: mergeRequest(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql index 3b54a2e529b..d5e27ca7b69 100644 --- a/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql +++ b/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql @@ -1,6 +1,7 @@ query mergeRequestSubscribed($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { __typename + id issuable: mergeRequest(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql index b1ab1bcbe87..d480ff3d5ba 100644 --- a/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql +++ b/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql @@ -1,6 +1,7 @@ query mergeRequestTimeTracking($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { __typename + id issuable: mergeRequest(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql index 93a1c9ea925..65b9ef45260 100644 --- a/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql +++ b/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql @@ -1,6 +1,7 @@ query mergeRequestTodos($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { __typename + id issuable: mergeRequest(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql b/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql index 2bc42a0b011..c7f3adc9aca 100644 --- a/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql +++ b/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql @@ -3,6 +3,7 @@ query projectIssueMilestone($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { __typename + id issuable: issue(iid: $iid) { __typename id diff --git a/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql b/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql index a3ab1ebc872..d9eab18628d 100644 --- a/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql +++ b/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql @@ -3,6 +3,7 @@ query projectMilestones($fullPath: ID!, $title: String, $state: MilestoneStateEnum) { workspace: project(fullPath: $fullPath) { __typename + id attributes: milestones( searchTitle: $title state: $state diff --git a/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql b/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql index dd85eb1631b..90d1a7794ea 100644 --- a/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql +++ b/app/assets/javascripts/sidebar/queries/sidebarDetails.query.graphql @@ -1,6 +1,8 @@ query sidebarDetails($fullPath: ID!, $iid: String!) { project(fullPath: $fullPath) { + id issue(iid: $iid) { + id iid } } diff --git a/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql b/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql index 02498b18832..0505f88773d 100644 --- a/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql +++ b/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql @@ -1,6 +1,8 @@ query mergeRequestSidebarDetails($fullPath: ID!, $iid: String!) { project(fullPath: $fullPath) { + id mergeRequest(iid: $iid) { + id iid # currently unused. } } diff --git a/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql index 2e6bc8c36ba..809cb2c9f76 100644 --- a/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql @@ -1,6 +1,7 @@ mutation updateEpicTitle($input: UpdateEpicInput!) { updateIssuableTitle: updateEpic(input: $input) { epic { + id title } errors diff --git a/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql index 016c31ea096..a48c9e96fc2 100644 --- a/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql @@ -1,7 +1,7 @@ mutation mergeRequestSetLabels($input: MergeRequestSetLabelsInput!) { - mergeRequestSetLabels(input: $input) { + updateIssuableLabels: mergeRequestSetLabels(input: $input) { errors - mergeRequest { + issuable: mergeRequest { id labels { nodes { diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index 86580744ccc..a49ddac8c89 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -79,6 +79,20 @@ export default class SidebarMediator { }), ); } else { + const currentUserId = gon.current_user_id; + + if (currentUserId !== user.id) { + const currentUserReviewerOrAssignee = isReviewer + ? this.store.findReviewer({ id: currentUserId }) + : this.store.findAssignee({ id: currentUserId }); + + if (currentUserReviewerOrAssignee?.attention_requested) { + // Update current users attention_requested state + this.store.updateReviewer(currentUserId, 'attention_requested'); + this.store.updateAssignee(currentUserId, 'attention_requested'); + } + } + toast(sprintf(__('Requested attention from @%{username}'), { username: user.username })); } diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue index f07fb9d926a..e3aa29d5f89 100644 --- a/app/assets/javascripts/snippets/components/edit.vue +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -230,7 +230,7 @@ export default { <gl-button category="primary" type="submit" - variant="success" + variant="confirm" :disabled="updatePrevented" data-qa-selector="submit_button" data-testid="snippet-submit-btn" diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue index 8481ac2b9c9..86cbc2c31b3 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue @@ -90,7 +90,7 @@ export default { }; </script> <template> - <article class="file-holder snippet-file-content"> + <figure class="file-holder snippet-file-content" :aria-label="__('Code snippet')"> <blob-header :blob="blob" :active-viewer-type="viewer.type" @@ -105,5 +105,5 @@ export default { @[$options.BLOB_RENDER_EVENT_LOAD]="forceQuery" @[$options.BLOB_RENDER_EVENT_SHOW_SOURCE]="switchViewer" /> - </article> + </figure> </template> diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue index a5c98a7ad90..9b24c8afe37 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -113,7 +113,7 @@ export default { href: this.snippet.project ? joinPaths(this.snippet.project.webUrl, '-/snippets/new') : joinPaths('/', gon.relative_url_root, '/-/snippets/new'), - variant: 'success', + variant: 'confirm', category: 'secondary', }, { diff --git a/app/assets/javascripts/snippets/fragments/project.fragment.graphql b/app/assets/javascripts/snippets/fragments/project.fragment.graphql deleted file mode 100644 index 64bb2315c1b..00000000000 --- a/app/assets/javascripts/snippets/fragments/project.fragment.graphql +++ /dev/null @@ -1,6 +0,0 @@ -fragment SnippetProject on Snippet { - project { - fullPath - webUrl - } -} diff --git a/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql index f688868d1b9..8640c4725f4 100644 --- a/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql +++ b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql @@ -2,6 +2,7 @@ mutation CreateSnippet($input: CreateSnippetInput!) { createSnippet(input: $input) { errors snippet { + id webUrl } } diff --git a/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql index 548725f7357..99242c5d500 100644 --- a/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql +++ b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql @@ -2,6 +2,7 @@ mutation UpdateSnippet($input: UpdateSnippetInput!) { updateSnippet(input: $input) { errors snippet { + id webUrl } } diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql index cfe30c601ed..c8c4195e1cd 100644 --- a/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql +++ b/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql @@ -1,5 +1,6 @@ query sourceContent($project: ID!, $sourcePath: String!) { project(fullPath: $project) { + id fullPath file(path: $sourcePath) @client { title diff --git a/app/assets/javascripts/tabs/constants.js b/app/assets/javascripts/tabs/constants.js new file mode 100644 index 00000000000..3b84d7394d4 --- /dev/null +++ b/app/assets/javascripts/tabs/constants.js @@ -0,0 +1,20 @@ +export const ACTIVE_TAB_CLASSES = Object.freeze([ + 'active', + 'gl-tab-nav-item-active', + 'gl-tab-nav-item-active-indigo', +]); + +export const ACTIVE_PANEL_CLASS = 'active'; + +export const KEY_CODE_LEFT = 'ArrowLeft'; +export const KEY_CODE_UP = 'ArrowUp'; +export const KEY_CODE_RIGHT = 'ArrowRight'; +export const KEY_CODE_DOWN = 'ArrowDown'; + +export const ATTR_ARIA_CONTROLS = 'aria-controls'; +export const ATTR_ARIA_LABELLEDBY = 'aria-labelledby'; +export const ATTR_ARIA_SELECTED = 'aria-selected'; +export const ATTR_ROLE = 'role'; +export const ATTR_TABINDEX = 'tabindex'; + +export const TAB_SHOWN_EVENT = 'gl-tab-shown'; diff --git a/app/assets/javascripts/tabs/index.js b/app/assets/javascripts/tabs/index.js new file mode 100644 index 00000000000..44937e593e0 --- /dev/null +++ b/app/assets/javascripts/tabs/index.js @@ -0,0 +1,239 @@ +import { uniqueId } from 'lodash'; +import { + ACTIVE_TAB_CLASSES, + ATTR_ROLE, + ATTR_ARIA_CONTROLS, + ATTR_TABINDEX, + ATTR_ARIA_SELECTED, + ATTR_ARIA_LABELLEDBY, + ACTIVE_PANEL_CLASS, + KEY_CODE_LEFT, + KEY_CODE_UP, + KEY_CODE_RIGHT, + KEY_CODE_DOWN, + TAB_SHOWN_EVENT, +} from './constants'; + +export { TAB_SHOWN_EVENT }; + +/** + * The `GlTabsBehavior` class adds interactivity to tabs created by the `gl_tabs_nav` and + * `gl_tab_link_to` Rails helpers. + * + * Example using `href` references: + * + * ```haml + * = gl_tabs_nav({ class: 'js-my-tabs' }) do + * = gl_tab_link_to '#foo', item_active: true do + * = _('Foo') + * = gl_tab_link_to '#bar' do + * = _('Bar') + * + * .tab-content + * .tab-pane.active#foo + * .tab-pane#bar + * ``` + * + * ```javascript + * import { GlTabsBehavior } from '~/tabs'; + * + * const glTabs = new GlTabsBehavior(document.querySelector('.js-my-tabs')); + * ``` + * + * Example using `aria-controls` references: + * + * ```haml + * = gl_tabs_nav({ class: 'js-my-tabs' }) do + * = gl_tab_link_to '#', item_active: true, 'aria-controls': 'foo' do + * = _('Foo') + * = gl_tab_link_to '#', 'aria-controls': 'bar' do + * = _('Bar') + * + * .tab-content + * .tab-pane.active#foo + * .tab-pane#bar + * ``` + * + * ```javascript + * import { GlTabsBehavior } from '~/tabs'; + * + * const glTabs = new GlTabsBehavior(document.querySelector('.js-my-tabs')); + * ``` + * + * `GlTabsBehavior` can be used to replace Bootstrap tab implementations that cannot + * easily be rewritten in Vue. + * + * NOTE: Do *not* use `GlTabsBehavior` with markup generated by other means, as it may not + * work correctly. + * + * Tab panels must exist somewhere in the page for the tabs to control. Tab panels + * must: + * - be immediate children of a `.tab-content` element + * - have the `tab-pane` class + * - if the panel is active, have the `active` class + * - have a unique `id` attribute + * + * In order to associate tabs with panels, the tabs must reference their panel's + * `id` by having one of the following attributes: + * - `href`, e.g., `href="#the-panel-id"` (note the leading `#` in the value) + * - `aria-controls`, e.g., `aria-controls="the-panel-id"` (no leading `#`) + * + * Exactly one tab/panel must be active in the original markup. + * + * Call the `destroy` method on an instance to remove event listeners that were + * added during construction. Other DOM mutations (like ARIA attributes) are + * _not_ reverted. + */ +export class GlTabsBehavior { + /** + * Create a GlTabsBehavior instance. + * + * @param {HTMLElement} el The element created by the Rails `gl_tabs_nav` helper. + */ + constructor(el) { + if (!el) { + throw new Error('Cannot instantiate GlTabsBehavior without an element'); + } + + this.destroyFns = []; + this.tabList = el; + this.tabs = this.getTabs(); + this.activeTab = null; + + this.setAccessibilityAttrs(); + this.bindEvents(); + } + + setAccessibilityAttrs() { + this.tabList.setAttribute(ATTR_ROLE, 'tablist'); + this.tabs.forEach((tab) => { + if (!tab.hasAttribute('id')) { + tab.setAttribute('id', uniqueId('gl_tab_nav__tab_')); + } + + if (!this.activeTab && tab.classList.contains(ACTIVE_TAB_CLASSES[0])) { + this.activeTab = tab; + tab.setAttribute(ATTR_ARIA_SELECTED, 'true'); + tab.removeAttribute(ATTR_TABINDEX); + } else { + tab.setAttribute(ATTR_ARIA_SELECTED, 'false'); + tab.setAttribute(ATTR_TABINDEX, '-1'); + } + + tab.setAttribute(ATTR_ROLE, 'tab'); + tab.closest('.nav-item').setAttribute(ATTR_ROLE, 'presentation'); + + const tabPanel = this.getPanelForTab(tab); + if (!tab.hasAttribute(ATTR_ARIA_CONTROLS)) { + tab.setAttribute(ATTR_ARIA_CONTROLS, tabPanel.id); + } + + tabPanel.setAttribute(ATTR_ROLE, 'tabpanel'); + tabPanel.setAttribute(ATTR_ARIA_LABELLEDBY, tab.id); + }); + } + + bindEvents() { + this.tabs.forEach((tab) => { + this.bindEvent(tab, 'click', (event) => { + event.preventDefault(); + + if (tab !== this.activeTab) { + this.activateTab(tab); + } + }); + + this.bindEvent(tab, 'keydown', (event) => { + const { code } = event; + if (code === KEY_CODE_UP || code === KEY_CODE_LEFT) { + event.preventDefault(); + this.activatePreviousTab(); + } else if (code === KEY_CODE_DOWN || code === KEY_CODE_RIGHT) { + event.preventDefault(); + this.activateNextTab(); + } + }); + }); + } + + bindEvent(el, ...args) { + el.addEventListener(...args); + + this.destroyFns.push(() => { + el.removeEventListener(...args); + }); + } + + activatePreviousTab() { + const currentTabIndex = this.tabs.indexOf(this.activeTab); + + if (currentTabIndex <= 0) return; + + const previousTab = this.tabs[currentTabIndex - 1]; + this.activateTab(previousTab); + previousTab.focus(); + } + + activateNextTab() { + const currentTabIndex = this.tabs.indexOf(this.activeTab); + + if (currentTabIndex >= this.tabs.length - 1) return; + + const nextTab = this.tabs[currentTabIndex + 1]; + this.activateTab(nextTab); + nextTab.focus(); + } + + getTabs() { + return Array.from(this.tabList.querySelectorAll('.gl-tab-nav-item')); + } + + // eslint-disable-next-line class-methods-use-this + getPanelForTab(tab) { + const ariaControls = tab.getAttribute(ATTR_ARIA_CONTROLS); + + if (ariaControls) { + return document.querySelector(`#${ariaControls}`); + } + + return document.querySelector(tab.getAttribute('href')); + } + + activateTab(tabToActivate) { + // Deactivate active tab first + this.activeTab.setAttribute(ATTR_ARIA_SELECTED, 'false'); + this.activeTab.setAttribute(ATTR_TABINDEX, '-1'); + this.activeTab.classList.remove(...ACTIVE_TAB_CLASSES); + + const activePanel = this.getPanelForTab(this.activeTab); + activePanel.classList.remove(ACTIVE_PANEL_CLASS); + + // Now activate the given tab/panel + tabToActivate.setAttribute(ATTR_ARIA_SELECTED, 'true'); + tabToActivate.removeAttribute(ATTR_TABINDEX); + tabToActivate.classList.add(...ACTIVE_TAB_CLASSES); + + const tabPanel = this.getPanelForTab(tabToActivate); + tabPanel.classList.add(ACTIVE_PANEL_CLASS); + + this.activeTab = tabToActivate; + + this.dispatchTabShown(tabToActivate, tabPanel); + } + + // eslint-disable-next-line class-methods-use-this + dispatchTabShown(tab, activeTabPanel) { + const event = new CustomEvent(TAB_SHOWN_EVENT, { + bubbles: true, + detail: { + activeTabPanel, + }, + }); + + tab.dispatchEvent(event); + } + + destroy() { + this.destroyFns.forEach((destroy) => destroy()); + } +} diff --git a/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql b/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql index 70ba5c960be..bb1e7195b17 100644 --- a/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql +++ b/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql @@ -1,23 +1,23 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" fragment StateVersion on TerraformStateVersion { + id downloadPath serial updatedAt - createdByUser { ...User } - job { + id detailedStatus { + id detailsPath group icon label text } - pipeline { id path diff --git a/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql b/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql index 9453e32b1b5..4d26ea88ddf 100644 --- a/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql +++ b/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql @@ -3,13 +3,12 @@ query getStates($projectPath: ID!, $first: Int, $last: Int, $before: String, $after: String) { project(fullPath: $projectPath) { + id terraformStates(first: $first, last: $last, before: $before, after: $after) { count - nodes { ...State } - pageInfo { ...PageInfo } diff --git a/app/assets/javascripts/test_utils/simulate_drag.js b/app/assets/javascripts/test_utils/simulate_drag.js index 321315d531b..4f3f1365f4a 100644 --- a/app/assets/javascripts/test_utils/simulate_drag.js +++ b/app/assets/javascripts/test_utils/simulate_drag.js @@ -122,7 +122,6 @@ export default function simulateDrag(options) { const firstRect = getRect(firstEl); const lastRect = getRect(lastEl); - const startTime = new Date().getTime(); const duration = options.duration || 1000; simulateEvent(fromEl, 'pointerdown', { @@ -140,8 +139,28 @@ export default function simulateDrag(options) { toRect.cy = lastRect.y + lastRect.h + 50; } - const dragInterval = setInterval(() => { - const progress = (new Date().getTime() - startTime) / duration; + let startTime; + + // Called within dragFn when the drag should finish + const finishFn = () => { + if (options.ondragend) options.ondragend(); + + if (options.performDrop) { + simulateEvent(toEl, 'mouseup'); + } + + window.SIMULATE_DRAG_ACTIVE = 0; + }; + + const dragFn = (timestamp) => { + if (!startTime) { + startTime = timestamp; + } + + const elapsed = timestamp - startTime; + + // Make sure that progress maxes at 1 + const progress = Math.min(elapsed / duration, 1); const x = fromRect.cx + (toRect.cx - fromRect.cx) * progress; const y = fromRect.cy + (toRect.cy - fromRect.cy + options.extraHeight) * progress; const overEl = fromEl.ownerDocument.elementFromPoint(x, y); @@ -152,16 +171,15 @@ export default function simulateDrag(options) { }); if (progress >= 1) { - if (options.ondragend) options.ondragend(); - - if (options.performDrop) { - simulateEvent(toEl, 'mouseup'); - } - - clearInterval(dragInterval); - window.SIMULATE_DRAG_ACTIVE = 0; + // finish on next frame, so we can pause in the correct position for a frame + requestAnimationFrame(finishFn); + } else { + requestAnimationFrame(dragFn); } - }, 100); + }; + + // Start the drag animation + requestAnimationFrame(dragFn); return { target: fromEl, diff --git a/app/assets/javascripts/token_access/graphql/queries/get_ci_job_token_scope.query.graphql b/app/assets/javascripts/token_access/graphql/queries/get_ci_job_token_scope.query.graphql index d4f559c3701..0e5334b468f 100644 --- a/app/assets/javascripts/token_access/graphql/queries/get_ci_job_token_scope.query.graphql +++ b/app/assets/javascripts/token_access/graphql/queries/get_ci_job_token_scope.query.graphql @@ -1,5 +1,6 @@ query getCIJobTokenScope($fullPath: ID!) { project(fullPath: $fullPath) { + id ciCdSettings { jobTokenScopeEnabled } diff --git a/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql b/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql index bec0710a1dd..664991bc110 100644 --- a/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql +++ b/app/assets/javascripts/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql @@ -1,8 +1,10 @@ query getProjectsWithCIJobTokenScope($fullPath: ID!) { project(fullPath: $fullPath) { + id ciJobTokenScope { projects { nodes { + id name fullPath } diff --git a/app/assets/javascripts/ui_development_kit.js b/app/assets/javascripts/ui_development_kit.js deleted file mode 100644 index 1a3fd6c77ed..00000000000 --- a/app/assets/javascripts/ui_development_kit.js +++ /dev/null @@ -1,28 +0,0 @@ -import $ from 'jquery'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import Api from './api'; - -export default () => { - initDeprecatedJQueryDropdown($('#js-project-dropdown'), { - data: (term, callback) => { - Api.projects( - term, - { - order_by: 'last_activity_at', - }, - (data) => { - callback(data); - }, - ); - }, - text: (project) => project.name_with_namespace || project.name, - selectable: true, - fieldName: 'author_id', - filterable: true, - search: { - fields: ['name_with_namespace'], - }, - id: (data) => data.id, - isSelected: (data) => data.id === 2, - }); -}; diff --git a/app/assets/javascripts/user_lists/components/add_user_modal.vue b/app/assets/javascripts/user_lists/components/add_user_modal.vue index a8dde1f681e..e982d10f63b 100644 --- a/app/assets/javascripts/user_lists/components/add_user_modal.vue +++ b/app/assets/javascripts/user_lists/components/add_user_modal.vue @@ -19,7 +19,7 @@ export default { modalOptions: { actionPrimary: { text: s__('UserLists|Add'), - attributes: [{ 'data-testid': 'confirm-add-user-ids' }], + attributes: [{ 'data-testid': 'confirm-add-user-ids', variant: 'confirm' }], }, actionCancel: { text: s__('UserLists|Cancel'), diff --git a/app/assets/javascripts/user_lists/components/user_list.vue b/app/assets/javascripts/user_lists/components/user_list.vue index 4cf3f3010b9..e86b3f81daa 100644 --- a/app/assets/javascripts/user_lists/components/user_list.vue +++ b/app/assets/javascripts/user_lists/components/user_list.vue @@ -105,7 +105,7 @@ export default { <gl-button v-gl-modal="$options.ADD_USER_MODAL_ID" data-testid="add-users" - variant="success" + variant="confirm" > {{ $options.translations.addUserButtonLabel }} </gl-button> diff --git a/app/assets/javascripts/vue_alerts.js b/app/assets/javascripts/vue_alerts.js index abc1dd75645..b44f787cf30 100644 --- a/app/assets/javascripts/vue_alerts.js +++ b/app/assets/javascripts/vue_alerts.js @@ -1,7 +1,17 @@ import Vue from 'vue'; +import Cookies from 'js-cookie'; import { parseBoolean } from '~/lib/utils/common_utils'; import DismissibleAlert from '~/vue_shared/components/dismissible_alert.vue'; +const getCookieExpirationPeriod = (expirationPeriod) => { + const defaultExpirationPeriod = 30; + const alertExpirationPeriod = Number(expirationPeriod); + + return !expirationPeriod || Number.isNaN(alertExpirationPeriod) + ? defaultExpirationPeriod + : alertExpirationPeriod; +}; + const mountVueAlert = (el) => { const props = { html: el.innerHTML, @@ -10,11 +20,25 @@ const mountVueAlert = (el) => { ...el.dataset, dismissible: parseBoolean(el.dataset.dismissible), }; + const { dismissCookieName, dismissCookieExpire } = el.dataset; return new Vue({ el, - render(h) { - return h(DismissibleAlert, { props, attrs }); + render(createElement) { + return createElement(DismissibleAlert, { + props, + attrs, + on: { + alertDismissed() { + if (!dismissCookieName) { + return; + } + Cookies.set(dismissCookieName, true, { + expires: getCookieExpirationPeriod(dismissCookieExpire), + }); + }, + }, + }); }, }); }; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue index f4f611dfd1b..e115710b5d1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue @@ -1,7 +1,7 @@ <script> import { GlLink, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import { MANUAL_DEPLOY, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue index 6f10f788952..549cf64fb08 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue @@ -9,17 +9,20 @@ import { GlIntersectionObserver, } from '@gitlab/ui'; import { once } from 'lodash'; +import * as Sentry from '@sentry/browser'; import api from '~/api'; import { sprintf, s__, __ } from '~/locale'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; -import { EXTENSION_ICON_CLASS } from '../../constants'; +import { EXTENSION_ICON_CLASS, EXTENSION_ICONS } from '../../constants'; import StatusIcon from './status_icon.vue'; import Actions from './actions.vue'; +import { generateText } from './utils'; export const LOADING_STATES = { collapsedLoading: 'collapsedLoading', collapsedError: 'collapsedError', expandedLoading: 'expandedLoading', + expandedError: 'expandedError', }; export default { @@ -40,8 +43,8 @@ export default { data() { return { loadingState: LOADING_STATES.collapsedLoading, - collapsedData: null, - fullData: null, + collapsedData: {}, + fullData: [], isCollapsed: true, showFade: false, }; @@ -53,6 +56,9 @@ export default { widgetLoadingText() { return this.$options.i18n?.loading || __('Loading...'); }, + widgetErrorText() { + return this.$options.i18n?.error || __('Failed to load'); + }, isLoadingSummary() { return this.loadingState === LOADING_STATES.collapsedLoading; }, @@ -60,11 +66,16 @@ export default { return this.loadingState === LOADING_STATES.expandedLoading; }, isCollapsible() { - if (this.isLoadingSummary) { - return false; - } - - return true; + return !this.isLoadingSummary && this.loadingState !== LOADING_STATES.collapsedError; + }, + hasFullData() { + return this.fullData.length > 0; + }, + hasFetchError() { + return ( + this.loadingState === LOADING_STATES.collapsedError || + this.loadingState === LOADING_STATES.expandedError + ); }, collapseButtonLabel() { return sprintf( @@ -75,6 +86,7 @@ export default { ); }, statusIconName() { + if (this.hasFetchError) return EXTENSION_ICONS.error; if (this.isLoadingSummary) return null; return this.statusIcon(this.collapsedData); @@ -82,6 +94,20 @@ export default { tertiaryActionsButtons() { return this.tertiaryButtons ? this.tertiaryButtons() : undefined; }, + hydratedSummary() { + const structuredOutput = this.summary(this.collapsedData); + const summary = { + subject: generateText( + typeof structuredOutput === 'string' ? structuredOutput : structuredOutput.subject, + ), + }; + + if (structuredOutput.meta) { + summary.meta = generateText(structuredOutput.meta); + } + + return summary; + }, }, watch: { isCollapsed(newVal) { @@ -93,15 +119,7 @@ export default { }, }, mounted() { - this.fetchCollapsedData(this.$props) - .then((data) => { - this.collapsedData = data; - this.loadingState = null; - }) - .catch((e) => { - this.loadingState = LOADING_STATES.collapsedError; - throw e; - }); + this.loadCollapsedData(); }, methods: { triggerRedisTracking: once(function triggerRedisTracking() { @@ -114,8 +132,22 @@ export default { this.triggerRedisTracking(); }, + loadCollapsedData() { + this.loadingState = LOADING_STATES.collapsedLoading; + + this.fetchCollapsedData(this.$props) + .then((data) => { + this.collapsedData = data; + this.loadingState = null; + }) + .catch((e) => { + this.loadingState = LOADING_STATES.collapsedError; + + Sentry.captureException(e); + }); + }, loadAllData() { - if (this.fullData) return; + if (this.hasFullData) return; this.loadingState = LOADING_STATES.expandedLoading; @@ -125,10 +157,14 @@ export default { this.fullData = data; }) .catch((e) => { - this.loadingState = null; - throw e; + this.loadingState = LOADING_STATES.expandedError; + + Sentry.captureException(e); }); }, + isArray(arr) { + return Array.isArray(arr); + }, appear(index) { if (index === this.fullData.length - 1) { this.showFade = false; @@ -139,6 +175,7 @@ export default { this.showFade = true; } }, + generateText, }, EXTENSION_ICON_CLASS, }; @@ -153,20 +190,29 @@ export default { :icon-name="statusIconName" /> <div - class="media-body gl-display-flex gl-flex-direction-row!" + class="media-body gl-display-flex gl-flex-direction-row! gl-align-self-center" data-testid="widget-extension-top-level" > <div class="gl-flex-grow-1"> <template v-if="isLoadingSummary">{{ widgetLoadingText }}</template> - <div v-else v-safe-html="summary(collapsedData)"></div> + <template v-else-if="hasFetchError">{{ widgetErrorText }}</template> + <div v-else> + <span v-safe-html="hydratedSummary.subject"></span> + <template v-if="hydratedSummary.meta"> + <br /> + <span v-safe-html="hydratedSummary.meta" class="gl-font-sm"></span> + </template> + </div> </div> <actions :widget="$options.label || $options.name" :tertiary-buttons="tertiaryActionsButtons" /> - <div class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6"> + <div + v-if="isCollapsible" + class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6" + > <gl-button - v-if="isCollapsible" v-gl-tooltip :title="collapseButtonLabel" :aria-expanded="`${!isCollapsed}`" @@ -189,7 +235,7 @@ export default { <gl-loading-icon size="sm" inline /> {{ __('Loading...') }} </div> <smart-virtual-list - v-else-if="fullData" + v-else-if="hasFullData" :length="fullData.length" :remain="20" :size="32" @@ -203,37 +249,64 @@ export default { :class="{ 'gl-border-b-solid gl-border-b-1 gl-border-gray-100': index !== fullData.length - 1, }" - class="gl-display-flex gl-align-items-center gl-py-3 gl-pl-7" + class="gl-py-3 gl-pl-7" data-testid="extension-list-item" > - <status-icon v-if="data.icon" :icon-name="data.icon.name" :size="12" class="gl-pl-0" /> - <gl-intersection-observer - :options="{ rootMargin: '100px', thresholds: 0.1 }" - class="gl-flex-wrap gl-display-flex gl-w-full" - @appear="appear(index)" - @disappear="disappear(index)" - > - <div - v-safe-html="data.text" - class="gl-mr-4 gl-display-flex gl-align-items-center" - ></div> - <div v-if="data.link"> - <gl-link :href="data.link.href">{{ data.link.text }}</gl-link> + <div class="gl-w-full"> + <div v-if="data.header" class="gl-mb-2"> + <template v-if="isArray(data.header)"> + <component + :is="headerI === 0 ? 'strong' : 'span'" + v-for="(header, headerI) in data.header" + :key="headerI" + v-safe-html="generateText(header)" + class="gl-display-block" + /> + </template> + <strong v-else v-safe-html="generateText(data.header)"></strong> + </div> + <div class="gl-display-flex"> + <status-icon + v-if="data.icon" + :icon-name="data.icon.name" + :size="12" + class="gl-pl-0" + /> + <gl-intersection-observer + :options="{ rootMargin: '100px', thresholds: 0.1 }" + class="gl-w-full" + @appear="appear(index)" + @disappear="disappear(index)" + > + <div class="gl-flex-wrap gl-display-flex gl-w-full"> + <div class="gl-mr-4 gl-display-flex gl-align-items-center"> + <p v-safe-html="generateText(data.text)" class="gl-m-0"></p> + </div> + <div v-if="data.link"> + <gl-link :href="data.link.href">{{ data.link.text }}</gl-link> + </div> + <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'"> + {{ data.badge.text }} + </gl-badge> + <actions + :widget="$options.label || $options.name" + :tertiary-buttons="data.actions" + class="gl-ml-auto" + /> + </div> + <p + v-if="data.subtext" + v-safe-html="generateText(data.subtext)" + class="gl-m-0 gl-font-sm" + ></p> + </gl-intersection-observer> </div> - <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'"> - {{ data.badge.text }} - </gl-badge> - <actions - :widget="$options.label || $options.name" - :tertiary-buttons="data.actions" - class="gl-ml-auto" - /> - </gl-intersection-observer> + </div> </li> </smart-virtual-list> <div :class="{ show: showFade }" - class="fade mr-extenson-scrim gl-absolute gl-left-0 gl-bottom-0 gl-w-full gl-h-7" + class="fade mr-extenson-scrim gl-absolute gl-left-0 gl-bottom-0 gl-w-full gl-h-7 gl-pointer-events-none" ></div> </div> </section> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js new file mode 100644 index 00000000000..8ba13cf8252 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/utils.js @@ -0,0 +1,62 @@ +const TEXT_STYLES = { + success: { + start: '%{success_start}', + end: '%{success_end}', + }, + danger: { + start: '%{danger_start}', + end: '%{danger_end}', + }, + critical: { + start: '%{critical_start}', + end: '%{critical_end}', + }, + same: { + start: '%{same_start}', + end: '%{same_end}', + }, + strong: { + start: '%{strong_start}', + end: '%{strong_end}', + }, + small: { + start: '%{small_start}', + end: '%{small_end}', + }, +}; + +const getStartTag = (tag) => TEXT_STYLES[tag].start; +const textStyleTags = { + [getStartTag('success')]: '<span class="gl-font-weight-bold gl-text-green-500">', + [getStartTag('danger')]: '<span class="gl-font-weight-bold gl-text-red-500">', + [getStartTag('critical')]: '<span class="gl-font-weight-bold gl-text-red-800">', + [getStartTag('same')]: '<span class="gl-font-weight-bold gl-text-gray-700">', + [getStartTag('strong')]: '<span class="gl-font-weight-bold">', + [getStartTag('small')]: '<span class="gl-font-sm">', +}; + +export const generateText = (text) => { + if (typeof text !== 'string') return null; + + return text + .replace( + new RegExp( + `(${Object.values(TEXT_STYLES) + .reduce((acc, i) => [...acc, ...Object.values(i)], []) + .join('|')})`, + 'gi', + ), + (replace) => { + const replacement = textStyleTags[replace]; + + // If the replacement tag ends with a `_end` then we can just return `</span>` + // unless we have a replacement, for cases were we want to change the HTML tag + if (!replacement && replace.endsWith('_end}')) { + return '</span>'; + } + + return replacement; + }, + ) + .replace(/%{([a-z]|_)+}/g, ''); // Filter out any tags we don't know about +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index 9070cb1fe65..235a200b747 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -13,7 +13,7 @@ import { import { constructWebIDEPath } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import clipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; import MrWidgetHowToMergeModal from './mr_widget_how_to_merge_modal.vue'; import MrWidgetIcon from './mr_widget_icon.vue'; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index f7c952f9ef6..c0b80eef082 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -15,7 +15,7 @@ import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mi import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { MT_MERGE_STRATEGY } from '../constants'; export default { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue index c314261d3f5..730d11b1208 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue @@ -1,9 +1,13 @@ <script> +import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { s__, n__ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { name: 'MRWidgetRelatedLinks', + directives: { + SafeHtml, + }, mixins: [glFeatureFlagMixin()], props: { relatedLinks: { @@ -43,14 +47,14 @@ export default { :class="{ 'gl-display-line gl-m-0': glFeatures.restructuredMrWidget }" > {{ closesText }} - <span v-html="relatedLinks.closing /* eslint-disable-line vue/no-v-html */"></span> + <span v-safe-html="relatedLinks.closing"></span> </p> <p v-if="relatedLinks.mentioned" :class="{ 'gl-display-line gl-m-0': glFeatures.restructuredMrWidget }" > {{ n__('mrWidget|Mentions issue', 'mrWidget|Mentions issues', relatedLinks.mentionedCount) }} - <span v-html="relatedLinks.mentioned /* eslint-disable-line vue/no-v-html */"></span> + <span v-safe-html="relatedLinks.mentioned"></span> </p> <p v-if="relatedLinks.assignToMe && showAssignToMe" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue index 3eda2828e97..18761d04c2e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue @@ -41,7 +41,6 @@ export default { rows="7" @input="$emit('input', $event.target.value)" ></textarea> - <slot name="text-muted"></slot> </div> </li> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue index 503ddf8a396..ce572f8b0bf 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue @@ -9,7 +9,7 @@ export default { pipelineFailed: s__( 'mrWidget|The pipeline for this merge request did not complete. Push a new commit to fix the failure.', ), - approvalNeeded: s__('mrWidget|You can only merge once this merge request is approved.'), + approvalNeeded: s__('mrWidget|Merge blocked: this merge request must be approved.'), unresolvedDiscussions: s__('mrWidget|Merge blocked: all threads must be resolved.'), }, components: { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue index 29c26f4fb3e..13b1e49f44e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue @@ -20,7 +20,7 @@ export default { </div> <div class="media-body"> <span class="bold"> - {{ s__('mrWidget|This project is archived, write access has been disabled') }} + {{ s__('mrWidget|Merge unavailable: merge requests are read-only on archived projects.') }} </span> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue index 1596f852b74..7a002d41ac0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue @@ -117,11 +117,12 @@ export default { </span> <template v-else> <span class="bold"> - {{ s__('mrWidget|There are merge conflicts') }}<span v-if="!canMerge">.</span> + {{ s__('mrWidget|Merge blocked: merge conflicts must be resolved.') }} <span v-if="!canMerge"> {{ - s__(`mrWidget|Resolve these conflicts or ask someone - with write access to this repository to merge it locally`) + s__( + `mrWidget|Users who can write to the source or target branches can resolve the conflicts.`, + ) }} </span> </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index 9f2870d8d69..01e8303f513 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -121,9 +121,6 @@ export default { if (res.merge_error && res.merge_error.length) { this.rebasingError = res.merge_error; - createFlash({ - message: __('Something went wrong. Please try again.'), - }); } eventHub.$emit('MRWidgetRebaseSuccess'); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index d2cc99302a9..8830128b7d6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -181,9 +181,16 @@ export default { return this.mr.canRemoveSourceBranch; }, commitTemplateHelpPage() { - return helpPagePath('user/project/merge_requests/commit_templates.md', { - anchor: 'merge-commit-message-template', - }); + return helpPagePath('user/project/merge_requests/commit_templates.md'); + }, + commitTemplateHintText() { + if (this.shouldShowSquashEdit && this.shouldShowMergeEdit) { + return this.$options.i18n.mergeAndSquashCommitTemplatesHintText; + } + if (this.shouldShowSquashEdit) { + return this.$options.i18n.squashCommitTemplateHintText; + } + return this.$options.i18n.mergeCommitTemplateHintText; }, commits() { if (this.glFeatures.mergeRequestWidgetGraphql) { @@ -287,7 +294,7 @@ export default { return false; } - return enableSquashBeforeMerge && this.commitsCount > 1; + return enableSquashBeforeMerge; }, shouldShowMergeControls() { if (this.glFeatures.restructuredMrWidget) { @@ -509,6 +516,12 @@ export default { mergeCommitTemplateHintText: s__( 'mrWidget|To change this default message, edit the template for merge commit messages. %{linkStart}Learn more.%{linkEnd}', ), + squashCommitTemplateHintText: s__( + 'mrWidget|To change this default message, edit the template for squash commit messages. %{linkStart}Learn more.%{linkEnd}', + ), + mergeAndSquashCommitTemplatesHintText: s__( + 'mrWidget|To change these default messages, edit the templates for both the merge and squash commit messages. %{linkStart}Learn more.%{linkEnd}', + ), }, }; </script> @@ -590,13 +603,7 @@ export default { :class="{ 'gl-w-full gl-order-n1 gl-mb-5': glFeatures.restructuredMrWidget }" class="gl-display-flex gl-align-items-center gl-flex-wrap" > - <merge-train-helper-icon - v-if="shouldRenderMergeTrainHelperIcon" - :merge-train-when-pipeline-succeeds-docs-path=" - mr.mergeTrainWhenPipelineSucceedsDocsPath - " - class="gl-mx-3" - /> + <merge-train-helper-icon v-if="shouldRenderMergeTrainHelperIcon" class="gl-mx-3" /> <gl-form-checkbox v-if="canRemoveSourceBranch" @@ -680,23 +687,22 @@ export default { :label="__('Merge commit message')" input-id="merge-message-edit" class="gl-m-0! gl-p-0!" - > - <template #text-muted> - <p class="form-text text-muted"> - <gl-sprintf :message="$options.i18n.mergeCommitTemplateHintText"> - <template #link="{ content }"> - <gl-link - :href="commitTemplateHelpPage" - class="inline-link" - target="_blank" - > - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </p> - </template> - </commit-edit> + /> + <li class="gl-m-0! gl-p-0!"> + <p class="form-text text-muted"> + <gl-sprintf :message="commitTemplateHintText"> + <template #link="{ content }"> + <gl-link + :href="commitTemplateHelpPage" + class="inline-link" + target="_blank" + > + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + </li> </ul> </div> <div @@ -798,19 +804,18 @@ export default { v-model="commitMessage" :label="__('Merge commit message')" input-id="merge-message-edit" - > - <template #text-muted> - <p class="form-text text-muted"> - <gl-sprintf :message="$options.i18n.mergeCommitTemplateHintText"> - <template #link="{ content }"> - <gl-link :href="commitTemplateHelpPage" class="inline-link" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </p> - </template> - </commit-edit> + /> + <li> + <p class="form-text text-muted"> + <gl-sprintf :message="commitTemplateHintText"> + <template #link="{ content }"> + <gl-link :href="commitTemplateHelpPage" class="inline-link" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + </li> </ul> </commits-header> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue index fa4f8b76cb9..ba831a33b73 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue @@ -165,13 +165,12 @@ export default { <div class="mr-widget-body media"> <status-icon :show-disabled-button="canUpdate" status="warning" /> <div class="media-body"> - <div class="gl-ml-3 float-left"> + <div class="float-left"> <span class="gl-font-weight-bold"> - {{ __('This merge request is still a draft.') }} + {{ + __("Merge blocked: merge request must be marked as ready. It's still marked as draft.") + }} </span> - <span class="gl-display-block text-muted">{{ - __("Draft merge requests can't be merged.") - }}</span> </div> <gl-button v-if="canUpdate" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue b/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue index 87a310efe78..1e5f7361966 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue @@ -20,8 +20,8 @@ export default { 'Terraform|Reported Resource Changes: %{addNum} to add, %{changeNum} to change, %{deleteNum} to delete', ), generationErrored: s__('Terraform|Generating the report caused an error.'), - namedReportFailed: s__('Terraform|The report %{name} failed to generate.'), - namedReportGenerated: s__('Terraform|The report %{name} was generated in your pipelines.'), + namedReportFailed: s__('Terraform|The job %{name} failed to generate a report.'), + namedReportGenerated: s__('Terraform|The job %{name} generated a report.'), reportFailed: s__('Terraform|A report failed to generate.'), reportGenerated: s__('Terraform|A report was generated in your pipelines.'), }, diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index d0c6cf12e25..2edccce7f4e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -50,6 +50,18 @@ export const MERGE_ACTIVE_STATUS_PHRASES = [ message: s__('mrWidget|Merging! This is going to be great…'), emoji: 'heart_eyes', }, + { + message: s__('mrWidget|Merging! Lift-off in 5… 4… 3…'), + emoji: 'rocket', + }, + { + message: s__('mrWidget|Merging! The changes are leaving the station…'), + emoji: 'bullettrain_front', + }, + { + message: s__('mrWidget|Merging! Take a deep breath and relax…'), + emoji: 'sunglasses', + }, ]; const STATE_MACHINE = { @@ -146,4 +158,7 @@ export const EXTENSION_ICON_CLASS = { severityUnknown: 'gl-text-gray-400', }; +export const EXTENSION_SUMMARY_FAILED_CLASS = 'gl-text-red-500'; +export const EXTENSION_SUMMARY_NEUTRAL_CLASS = 'gl-text-gray-700'; + export { STATE_MACHINE }; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js index 9cbc0b0e5d1..ba3336df2eb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js @@ -2,6 +2,7 @@ import { EXTENSION_ICONS } from '../constants'; import issuesCollapsedQuery from './issues_collapsed.query.graphql'; import issuesQuery from './issues.query.graphql'; +import { n__, sprintf } from '~/locale'; export default { // Give the extension a name @@ -20,7 +21,14 @@ export default { // Small summary text to be displayed in the collapsed state // Receives the collapsed data as an argument summary(count) { - return 'Summary text<br/>Second line'; + return sprintf( + n__( + 'ciReport|Load performance test metrics detected %{strong_start}%{changesFound}%{strong_end} change', + 'ciReport|Load performance test metrics detected %{strong_start}%{changesFound}%{strong_end} changes', + changesFound, + ), + { changesFound }, + ); }, // Status icon to be used next to the summary text // Receives the collapsed data as an argument @@ -57,9 +65,13 @@ export default { .query({ query: issuesQuery, variables: { projectPath: targetProjectFullPath } }) .then(({ data }) => { // Return some transformed data to be rendered in the expanded state - return data.project.issues.nodes.map((issue) => ({ + return data.project.issues.nodes.map((issue, i) => ({ id: issue.id, // Required: The ID of the object - text: issue.title, // Required: The text to get used on each row + header: ['New', 'This is an %{strong_start}issue%{strong_end} row'], + text: + '%{critical_start}1 Critical%{critical_end}, %{danger_start}1 High%{danger_end}, and %{strong_start}1 Other%{strong_end}. %{small_start}Some smaller text%{small_end}', // Required: The text to get used on each row + subtext: + 'Reported resource changes: %{strong_start}2%{strong_end} to add, 0 to change, 0 to delete', // Optional: The sub-text to get displayed below each rows main content // Icon to get rendered on the side of each row icon: { // Required: Name maps to an icon in GitLabs SVG diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.query.graphql b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.query.graphql index 690f571c083..5c54560bd02 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.query.graphql @@ -1,5 +1,6 @@ query getAllIssues($projectPath: ID!) { project(fullPath: $projectPath) { + id issues { nodes { id diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql b/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql index da1cace4598..bf278e1ea85 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql @@ -1,5 +1,6 @@ query getProjectIssues($projectPath: ID!) { project(fullPath: $projectPath) { + id issues { count } diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js index 83789f10285..fa618756bb5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js @@ -1,6 +1,8 @@ import { __ } from '~/locale'; -export const MERGE_DISABLED_TEXT = __('You can only merge once the items above are resolved.'); +export const MERGE_DISABLED_TEXT = __( + 'Merge blocked: all merge request dependencies must be merged or closed.', +); export const MERGE_DISABLED_SKIPPED_PIPELINE_TEXT = __( "Merge blocked: pipeline must succeed. It's waiting for a manual job to continue.", ); diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql index bfb1517be81..0b8396b4461 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql @@ -1,9 +1,11 @@ query getState($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { + id archived onlyAllowMergeIfPipelineSucceeds mergeRequest(iid: $iid) { + id autoMergeEnabled commitCount conflicts diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/permissions.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/permissions.query.graphql index ae2a67440fe..7ca3ff39fbe 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/permissions.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/permissions.query.graphql @@ -1,6 +1,8 @@ query userPermissionsQuery($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { + id mergeRequest(iid: $iid) { + id userPermissions { canMerge pushToSourceBranch diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.fragment.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.fragment.graphql index ad715599eb1..fc25e699e39 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.fragment.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.fragment.graphql @@ -1,4 +1,5 @@ fragment autoMergeEnabled on MergeRequest { + id autoMergeStrategy mergeUser { id diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql index e0215fbd969..2d79d35cf24 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql @@ -2,6 +2,7 @@ query autoMergeEnabled($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { + id mergeRequest(iid: $iid) { ...autoMergeEnabled } diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_failed.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_failed.query.graphql index 2fe0d174b67..da8aeab9dcb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_failed.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_failed.query.graphql @@ -1,6 +1,8 @@ query autoMergeFailedQuery($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { + id mergeRequest(iid: $iid) { + id mergeError } } diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql index e66ac01ab12..faf21b28f86 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql @@ -1,6 +1,8 @@ query workInProgress($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { + id mergeRequest(iid: $iid) { + id shouldBeRebased sourceBranchProtected } diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/draft.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/draft.query.graphql index 0983c28448e..54f2233439f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/draft.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/draft.query.graphql @@ -1,6 +1,8 @@ query mrUserPermission($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { + id mergeRequest(iid: $iid) { + id userPermissions { updateMergeRequest } diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/missing_branch.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/missing_branch.query.graphql index ea95218aec6..4d87d55f671 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/missing_branch.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/missing_branch.query.graphql @@ -1,6 +1,8 @@ query missingBranchQuery($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { + id mergeRequest(iid: $iid) { + id sourceBranchExists } } diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql index 21c3ffd8321..73c9e77b7bc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql @@ -1,6 +1,8 @@ query getReadyToMergeStatus($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { + id mergeRequest(iid: $iid) { + id userPermissions { canMerge } diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql index b2a1be5c5a9..d85794f7245 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql @@ -1,8 +1,10 @@ fragment ReadyToMerge on Project { + id onlyAllowMergeIfPipelineSucceeds mergeRequestsFfOnlyEnabled squashReadOnly mergeRequest(iid: $iid) { + id autoMergeEnabled shouldRemoveSourceBranch forceRemoveSourceBranch @@ -26,6 +28,7 @@ fragment ReadyToMerge on Project { mergeError commitsWithoutMergeCommits { nodes { + id sha shortId title diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/rebase.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/rebase.query.graphql index a8c7d2610bf..283177267d4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/rebase.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/rebase.query.graphql @@ -1,6 +1,8 @@ query rebaseQuery($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { + id mergeRequest(iid: $iid) { + id rebaseInProgress targetBranch userPermissions { diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/toggle_draft.mutation.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/toggle_draft.mutation.graphql index 200fb1b7ca5..022629bb802 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/toggle_draft.mutation.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/toggle_draft.mutation.graphql @@ -1,6 +1,7 @@ mutation toggleDraftStatus($projectPath: ID!, $iid: String!, $draft: Boolean!) { mergeRequestSetDraft(input: { projectPath: $projectPath, iid: $iid, draft: $draft }) { mergeRequest { + id mergeableDiscussionsState title draft diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 10a2907c81a..57af869a0ba 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -59,7 +59,6 @@ export default class MergeRequestStore { this.sourceBranch = data.source_branch; this.sourceBranchProtected = data.source_branch_protected; this.conflictsDocsPath = data.conflicts_docs_path; - this.mergeTrainWhenPipelineSucceedsDocsPath = data.merge_train_when_pipeline_succeeds_docs_path; this.commitMessage = data.default_merge_commit_message; this.shortMergeCommitSha = data.short_merged_commit_sha; this.mergeCommitSha = data.merged_commit_sha; diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue index c24318cb9ad..489d4afa41f 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue @@ -220,16 +220,17 @@ export default { class="gl-text-gray-900 gl-mb-2 gl-line-height-20 gl-display-flex gl-justify-content-space-between" > {{ __('Assignee') }} - <a + <gl-button v-if="isEditable" ref="editButton" - class="btn-link" - href="#" + category="tertiary" + size="small" + class="gl-text-black-normal!" @click="toggleFormDropdown" @keydown.esc="hideDropdown" > {{ __('Edit') }} - </a> + </gl-button> </p> <gl-dropdown diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue index eaa5fc5af04..c512585b980 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue @@ -100,7 +100,8 @@ export default { <gl-button v-if="isEditable" class="gl-text-black-normal!" - variant="link" + category="tertiary" + size="small" @click="toggleFormDropdown" @keydown.esc="hideDropdown" > diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql index f0095abfca1..0460d250f75 100644 --- a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql +++ b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql @@ -2,6 +2,7 @@ mutation createAlertIssue($projectPath: ID!, $iid: String!) { createAlertIssue(input: { iid: $iid, projectPath: $projectPath }) { errors issue { + id iid webUrl } diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql index 0c26fcc0ab2..0ea209ffd39 100644 --- a/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql +++ b/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_details.query.graphql @@ -3,6 +3,7 @@ query alertDetailsAssignees($fullPath: ID!, $alertId: String) { project(fullPath: $fullPath) { + id alertManagementAlerts(iid: $alertId) { nodes { ...AlertDetailItem diff --git a/app/assets/javascripts/vue_shared/components/chronic_duration_input.vue b/app/assets/javascripts/vue_shared/components/chronic_duration_input.vue new file mode 100644 index 00000000000..ffbcdefc924 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/chronic_duration_input.vue @@ -0,0 +1,133 @@ +<script> +import * as Sentry from '@sentry/browser'; +import { GlFormInput } from '@gitlab/ui'; +import { + DurationParseError, + outputChronicDuration, + parseChronicDuration, +} from '~/chronic_duration'; +import { __ } from '~/locale'; + +export default { + components: { + GlFormInput, + }, + model: { + prop: 'value', + event: 'change', + }, + props: { + value: { + type: Number, + required: false, + default: null, + }, + name: { + type: String, + required: false, + default: null, + }, + integerRequired: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + return { + numberData: this.value, + humanReadableData: this.convertDuration(this.value), + isValueValid: this.value === null ? null : true, + }; + }, + computed: { + numberValue: { + get() { + return this.numberData; + }, + set(value) { + if (this.numberData !== value) { + this.numberData = value; + this.humanReadableData = this.convertDuration(value); + this.isValueValid = value === null ? null : true; + } + this.emitEvents(); + }, + }, + humanReadableValue: { + get() { + return this.humanReadableData; + }, + set(value) { + this.humanReadableData = value; + try { + if (value === '') { + this.numberData = null; + this.isValueValid = null; + } else { + this.numberData = parseChronicDuration(value, { + keepZero: true, + raiseExceptions: true, + }); + this.isValueValid = true; + } + } catch (e) { + if (e instanceof DurationParseError) { + this.isValueValid = false; + } else { + Sentry.captureException(e); + } + } + this.emitEvents(true); + }, + }, + isValidDecimal() { + return !this.integerRequired || this.numberData === null || Number.isInteger(this.numberData); + }, + feedback() { + if (this.isValueValid === false) { + return this.$options.i18n.INVALID_INPUT_FEEDBACK; + } + if (!this.isValidDecimal) { + return this.$options.i18n.INVALID_DECIMAL_FEEDBACK; + } + return ''; + }, + }, + i18n: { + INVALID_INPUT_FEEDBACK: __('Please enter a valid time interval'), + INVALID_DECIMAL_FEEDBACK: __('An integer value is required for seconds'), + }, + watch: { + value() { + this.numberValue = this.value; + }, + }, + mounted() { + this.emitEvents(); + }, + methods: { + convertDuration(value) { + return value === null ? '' : outputChronicDuration(value); + }, + emitEvents(emitChange = false) { + if (emitChange && this.isValueValid !== false && this.isValidDecimal) { + this.$emit('change', this.numberData); + } + const { feedback } = this; + this.$refs.text.$el.setCustomValidity(feedback); + this.$refs.hidden.setCustomValidity(feedback); + this.$emit('valid', { + valid: this.isValueValid && this.isValidDecimal, + feedback, + }); + }, + }, +}; +</script> +<template> + <div> + <gl-form-input ref="text" v-bind="$attrs" v-model="humanReadableValue" /> + <input ref="hidden" type="hidden" :name="name" :value="numberValue" /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue index fe329b18f30..400be3ef688 100644 --- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue +++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue @@ -66,6 +66,11 @@ export default { required: false, default: 'medium', }, + variant: { + type: String, + required: false, + default: 'default', + }, }, computed: { clipboardText() { @@ -92,6 +97,7 @@ export default { :size="size" icon="copy-to-clipboard" :aria-label="__('Copy this value')" + :variant="variant" v-on="$listeners" > <slot></slot> diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue index 5f50a699034..ebbc1bfb037 100644 --- a/app/assets/javascripts/vue_shared/components/commit.vue +++ b/app/assets/javascripts/vue_shared/components/commit.vue @@ -2,7 +2,7 @@ import { GlTooltipDirective, GlLink, GlIcon } from '@gitlab/ui'; import { isString, isEmpty } from 'lodash'; import { __, sprintf } from '~/locale'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import UserAvatarLink from './user_avatar/user_avatar_link.vue'; export default { diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue index 4c07cf44fed..f93415ced45 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue @@ -26,6 +26,11 @@ export default { type: String, required: true, }, + buttonClass: { + type: String, + required: false, + default: '', + }, buttonTestid: { type: String, required: false, @@ -39,7 +44,7 @@ export default { <div> <gl-button v-gl-modal="$options.modalId" - class="gl-button" + :class="buttonClass" variant="danger" :disabled="disabled" :data-testid="buttonTestid" diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue index 30c96daf7e3..5bbe44b20b3 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue @@ -47,7 +47,7 @@ export default { actionPrimary() { return { text: this.confirmButtonText, - attributes: [{ variant: 'danger', disabled: !this.isValid }], + attributes: [{ variant: 'danger', disabled: !this.isValid, class: 'qa-confirm-button' }], }; }, }, @@ -95,7 +95,7 @@ export default { <gl-form-input id="confirm_name_input" v-model="confirmationPhrase" - class="form-control" + class="form-control qa-confirm-input" data-testid="confirm-danger-input" type="text" /> diff --git a/app/assets/javascripts/vue_shared/components/confirm_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_modal.vue index 7c1d3772acd..72504e5bc50 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_modal.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_modal.vue @@ -2,10 +2,13 @@ import { GlModal, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import csrf from '~/lib/utils/csrf'; +import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from './confirm_modal_eventhub'; +import DomElementListener from './dom_element_listener.vue'; export default { components: { GlModal, + DomElementListener, }, directives: { SafeHtml, @@ -30,18 +33,35 @@ export default { }; }, mounted() { - document.querySelectorAll(this.selector).forEach((button) => { - button.addEventListener('click', (e) => { - e.preventDefault(); - - this.path = button.dataset.path; - this.method = button.dataset.method; - this.modalAttributes = JSON.parse(button.dataset.modalAttributes); - this.openModal(); - }); - }); + eventHub.$on(EVENT_OPEN_CONFIRM_MODAL, this.onOpenEvent); + }, + destroyed() { + eventHub.$off(EVENT_OPEN_CONFIRM_MODAL, this.onOpenEvent); }, methods: { + onButtonPress(e) { + const element = e.currentTarget; + + if (!element.dataset.path) { + return; + } + + const modalAttributes = element.dataset.modalAttributes + ? JSON.parse(element.dataset.modalAttributes) + : {}; + + this.onOpenEvent({ + path: element.dataset.path, + method: element.dataset.method, + modalAttributes, + }); + }, + onOpenEvent({ path, method, modalAttributes }) { + this.path = path; + this.method = method; + this.modalAttributes = modalAttributes; + this.openModal(); + }, openModal() { this.$refs.modal.show(); }, @@ -61,21 +81,23 @@ export default { </script> <template> - <gl-modal - ref="modal" - :modal-id="modalId" - v-bind="modalAttributes" - @primary="submitModal" - @cancel="closeModal" - > - <form ref="form" :action="path" method="post"> - <!-- Rails workaround for <form method="delete" /> + <dom-element-listener :selector="selector" @click.prevent="onButtonPress"> + <gl-modal + ref="modal" + :modal-id="modalId" + v-bind="modalAttributes" + @primary="submitModal" + @cancel="closeModal" + > + <form ref="form" :action="path" method="post"> + <!-- Rails workaround for <form method="delete" /> https://github.com/rails/rails/blob/master/actionview/app/assets/javascripts/rails-ujs/features/method.coffee --> - <input type="hidden" name="_method" :value="method" /> - <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> - <div v-if="modalAttributes.messageHtml" v-safe-html="modalAttributes.messageHtml"></div> - <div v-else>{{ modalAttributes.message }}</div> - </form> - </gl-modal> + <input type="hidden" name="_method" :value="method" /> + <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> + <div v-if="modalAttributes.messageHtml" v-safe-html="modalAttributes.messageHtml"></div> + <div v-else>{{ modalAttributes.message }}</div> + </form> + </gl-modal> + </dom-element-listener> </template> diff --git a/app/assets/javascripts/vue_shared/components/confirm_modal_eventhub.js b/app/assets/javascripts/vue_shared/components/confirm_modal_eventhub.js new file mode 100644 index 00000000000..f8d9d410ace --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/confirm_modal_eventhub.js @@ -0,0 +1,5 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); + +export const EVENT_OPEN_CONFIRM_MODAL = Symbol('OPEN'); diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue index 1a96cabf755..e546ca57c5e 100644 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue +++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue @@ -3,7 +3,7 @@ import { GlIcon, GlButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitl import { convertToFixedRange, isEqualTimeRanges, findTimeRange } from '~/lib/utils/datetime_range'; import { __, sprintf } from '~/locale'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import DateTimePickerInput from './date_time_picker_input.vue'; import { defaultTimeRanges, diff --git a/app/assets/javascripts/design_management/components/design_note_pin.vue b/app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue index 320e0654aab..cb038a8c4e1 100644 --- a/app/assets/javascripts/design_management/components/design_note_pin.vue +++ b/app/assets/javascripts/vue_shared/components/design_management/design_note_pin.vue @@ -10,13 +10,24 @@ export default { props: { position: { type: Object, - required: true, + required: false, + default: null, }, label: { type: Number, required: false, default: null, }, + isResolved: { + type: Boolean, + required: false, + default: false, + }, + isInactive: { + type: Boolean, + required: false, + default: false, + }, }, computed: { isNewNote() { @@ -36,10 +47,13 @@ export default { :style="position" :aria-label="pinLabel" :class="{ - 'btn-transparent comment-indicator gl-p-0': isNewNote, - 'js-image-badge badge badge-pill': !isNewNote, + 'btn-transparent comment-indicator': isNewNote, + 'js-image-badge design-note-pin': !isNewNote, + resolved: isResolved, + inactive: isInactive, + 'gl-absolute': position, }" - class="gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-font-lg gl-outline-0!" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-font-sm" type="button" @mousedown="$emit('mousedown', $event)" @mouseup="$emit('mouseup', $event)" diff --git a/app/assets/javascripts/vue_shared/components/dismissible_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_alert.vue index 52371e42ba1..0621ec14c6c 100644 --- a/app/assets/javascripts/vue_shared/components/dismissible_alert.vue +++ b/app/assets/javascripts/vue_shared/components/dismissible_alert.vue @@ -24,6 +24,7 @@ export default { methods: { dismiss() { this.isDismissed = true; + this.$emit('alertDismissed'); }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/dom_element_listener.vue b/app/assets/javascripts/vue_shared/components/dom_element_listener.vue new file mode 100644 index 00000000000..ca427ed4897 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/dom_element_listener.vue @@ -0,0 +1,28 @@ +<script> +export default { + props: { + selector: { + type: String, + required: true, + }, + }, + mounted() { + this.disposables = Array.from(document.querySelectorAll(this.selector)).flatMap((button) => { + return Object.entries(this.$listeners).map(([key, value]) => { + button.addEventListener(key, value); + return () => { + button.removeEventListener(key, value); + }; + }); + }); + }, + destroyed() { + this.disposables.forEach((x) => { + x(); + }); + }, + render() { + return this.$slots.default; + }, +}; +</script> 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 index e1e71639115..8686d317c8a 100644 --- 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 @@ -6,15 +6,10 @@ const fileExtensionIcons = { jade: 'pug', pug: 'pug', md: 'markdown', - 'md.rendered': 'markdown', markdown: 'markdown', - 'markdown.rendered': 'markdown', mdown: 'markdown', - 'mdown.rendered': 'markdown', mkd: 'markdown', - 'mkd.rendered': 'markdown', mkdn: 'markdown', - 'mkdn.rendered': 'markdown', rst: 'markdown', blink: 'blink', css: 'css', @@ -23,7 +18,6 @@ const fileExtensionIcons = { less: 'less', json: 'json', yaml: 'yaml', - 'YAML-tmLanguage': 'yaml', yml: 'yaml', xml: 'xml', plist: 'xml', @@ -85,10 +79,7 @@ const fileExtensionIcons = { props: 'settings', toml: 'settings', prefs: 'settings', - 'sln.dotsettings': 'settings', - 'sln.dotsettings.user': 'settings', ts: 'typescript', - 'd.ts': 'typescript-def', marko: 'markojs', pdf: 'pdf', xlsx: 'table', @@ -99,7 +90,6 @@ const fileExtensionIcons = { vscodeignore: 'vscode', vsixmanifest: 'vscode', vsix: 'vscode', - 'code-workplace': 'vscode', suo: 'visualstudio', sln: 'visualstudio', csproj: 'visualstudio', @@ -118,7 +108,6 @@ const fileExtensionIcons = { xz: 'zip', bzip2: 'zip', gzip: 'zip', - '7z': 'zip', rar: 'zip', tgz: 'zip', exe: 'exe', @@ -129,7 +118,6 @@ const fileExtensionIcons = { c: 'c', m: 'c', h: 'h', - 'c++': 'cpp', cc: 'cpp', cpp: 'cpp', mm: 'cpp', @@ -231,7 +219,6 @@ const fileExtensionIcons = { m2v: 'movie', vdi: 'virtual', vbox: 'virtual', - 'vbox-prev': 'virtual', ics: 'email', mp3: 'music', flac: 'music', @@ -277,44 +264,12 @@ const fileExtensionIcons = { ml: 'ocaml', mli: 'ocaml', cmx: 'ocaml', - 'js.map': 'javascript-map', - 'css.map': 'css-map', lock: 'lock', hbs: 'handlebars', mustache: 'handlebars', pl: 'perl', pm: 'perl', hx: 'haxe', - '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', pp: 'puppet', ex: 'elixir', exs: 'elixir', @@ -345,11 +300,8 @@ const fileExtensionIcons = { haml: 'haml', yang: 'yang', tf: 'terraform', - 'tf.json': 'terraform', tfvars: 'terraform', tfstate: 'terraform', - 'blade.php': 'laravel', - 'inky.php': 'laravel', applescript: 'applescript', cake: 'cake', feature: 'cucumber', @@ -376,16 +328,68 @@ const fileExtensionIcons = { 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', - cr: 'crystal', 'drone.yml': 'drone', - cu: 'cuda', - cuh: 'cuda', - log: 'log', }; const fileNameIcons = { @@ -598,6 +602,9 @@ const fileNameIcons = { export default function getIconForFile(name) { return ( - fileNameIcons[name] || fileExtensionIcons[name ? name.split('.').pop().toLowerCase() : ''] || '' + 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 0b0a416b7ef..2227047a909 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -146,6 +146,7 @@ export default { ref="textOutput" :style="levelIndentation" class="file-row-name" + :title="file.name" data-qa-selector="file_name_content" :data-qa-file-name="file.name" data-testid="file-row-name-container" 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 d9290e86bca..810d9f782b9 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 @@ -2,7 +2,6 @@ import { __ } from '~/locale'; export const DEBOUNCE_DELAY = 200; export const MAX_RECENT_TOKENS_SIZE = 3; -export const WEIGHT_TOKEN_SUGGESTIONS_SIZE = 21; export const FILTER_NONE = 'None'; export const FILTER_ANY = 'Any'; @@ -24,22 +23,11 @@ export const DEFAULT_LABEL_NONE = { value: FILTER_NONE, text: __('None'), title: export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __('Any'), title: __('Any') }; export const DEFAULT_NONE_ANY = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; -export const DEFAULT_ITERATIONS = DEFAULT_NONE_ANY.concat([ - { value: FILTER_CURRENT, text: __('Current') }, -]); - export const DEFAULT_MILESTONES = DEFAULT_NONE_ANY.concat([ { value: FILTER_UPCOMING, text: __('Upcoming'), title: __('Upcoming') }, { value: FILTER_STARTED, text: __('Started'), title: __('Started') }, ]); -export const DEFAULT_MILESTONES_GRAPHQL = [ - { value: 'any', text: __('Any'), title: __('Any') }, - { value: 'none', text: __('None'), title: __('None') }, - { value: '#upcoming', text: __('Upcoming'), title: __('Upcoming') }, - { value: '#started', text: __('Started'), title: __('Started') }, -]; - export const SortDirection = { descending: 'descending', ascending: 'ascending', @@ -56,6 +44,3 @@ export const TOKEN_TITLE_TYPE = __('Type'); export const TOKEN_TITLE_RELEASE = __('Release'); export const TOKEN_TITLE_MY_REACTION = __('My-Reaction'); export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential'); -export const TOKEN_TITLE_ITERATION = __('Iteration'); -export const TOKEN_TITLE_EPIC = __('Epic'); -export const TOKEN_TITLE_WEIGHT = __('Weight'); diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql deleted file mode 100644 index 9e9bda8ad3e..00000000000 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql +++ /dev/null @@ -1,15 +0,0 @@ -fragment EpicNode on Epic { - id - iid - group { - fullPath - } - title - state - reference - referencePath: reference(full: true) - webPath - webUrl - createdAt - closedAt -} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql deleted file mode 100644 index 4bb4b586fc9..00000000000 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql +++ /dev/null @@ -1,16 +0,0 @@ -#import "./epic.fragment.graphql" - -query searchEpics($fullPath: ID!, $search: String, $state: EpicState) { - group(fullPath: $fullPath) { - epics( - search: $search - state: $state - includeAncestorGroups: true - includeDescendantGroups: false - ) { - nodes { - ...EpicNode - } - } - } -} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue index b3b3d5c88c6..06478a89721 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue @@ -87,7 +87,6 @@ export default { :get-active-token-value="getActiveAuthor" :default-suggestions="defaultAuthors" :preloaded-suggestions="preloadedAuthors" - :recent-suggestions-storage-key="config.recentSuggestionsStorageKey" @fetch-suggestions="fetchAuthors" v-on="$listeners" > diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue index cee7c40aa83..bbc1888bc0b 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue @@ -4,12 +4,17 @@ import { GlFilteredSearchSuggestion, GlDropdownDivider, GlDropdownSectionHeader, + GlDropdownText, GlLoadingIcon, } from '@gitlab/ui'; import { debounce } from 'lodash'; import { DEBOUNCE_DELAY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants'; -import { getRecentlyUsedSuggestions, setTokenValueToRecentlyUsed } from '../filtered_search_utils'; +import { + getRecentlyUsedSuggestions, + setTokenValueToRecentlyUsed, + stripQuotes, +} from '../filtered_search_utils'; export default { components: { @@ -17,6 +22,7 @@ export default { GlFilteredSearchSuggestion, GlDropdownDivider, GlDropdownSectionHeader, + GlDropdownText, GlLoadingIcon, }, props: { @@ -57,11 +63,6 @@ export default { required: false, default: () => [], }, - recentSuggestionsStorageKey: { - type: String, - required: false, - default: '', - }, valueIdentifier: { type: String, required: false, @@ -76,14 +77,14 @@ export default { data() { return { searchKey: '', - recentSuggestions: this.recentSuggestionsStorageKey - ? getRecentlyUsedSuggestions(this.recentSuggestionsStorageKey) + recentSuggestions: this.config.recentSuggestionsStorageKey + ? getRecentlyUsedSuggestions(this.config.recentSuggestionsStorageKey) : [], }; }, computed: { isRecentSuggestionsEnabled() { - return Boolean(this.recentSuggestionsStorageKey); + return Boolean(this.config.recentSuggestionsStorageKey); }, recentTokenIds() { return this.recentSuggestions.map((tokenValue) => tokenValue[this.valueIdentifier]); @@ -119,6 +120,9 @@ export default { showDefaultSuggestions() { return this.availableDefaultSuggestions.length > 0; }, + showNoMatchesText() { + return this.searchKey && !this.availableSuggestions.length; + }, showRecentSuggestions() { return ( this.isRecentSuggestionsEnabled && this.recentSuggestions.length > 0 && !this.searchKey @@ -163,11 +167,20 @@ export default { this.searchKey = data; if (!this.suggestionsLoading && !this.activeTokenValue) { - const search = this.searchTerm ? this.searchTerm : data; + let search = this.searchTerm ? this.searchTerm : data; + + if (search.startsWith('"') && search.endsWith('"')) { + search = stripQuotes(search); + } else if (search.startsWith('"')) { + search = search.slice(1, search.length); + } + this.$emit('fetch-suggestions', search); } }, DEBOUNCE_DELAY), - handleTokenValueSelected(activeTokenValue) { + handleTokenValueSelected(selectedValue) { + const activeTokenValue = this.getActiveTokenValue(this.suggestions, selectedValue); + // Make sure that; // 1. Recently used values feature is enabled // 2. User has actually selected a value @@ -177,7 +190,7 @@ export default { activeTokenValue && !this.preloadedTokenIds.includes(activeTokenValue[this.valueIdentifier]) ) { - setTokenValueToRecentlyUsed(this.recentSuggestionsStorageKey, activeTokenValue); + setTokenValueToRecentlyUsed(this.config.recentSuggestionsStorageKey, activeTokenValue); } }, }, @@ -192,7 +205,7 @@ export default { v-bind="$attrs" v-on="$listeners" @input="handleInput" - @select="handleTokenValueSelected(activeTokenValue)" + @select="handleTokenValueSelected" > <template #view-token="viewTokenProps"> <slot name="view-token" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot> @@ -222,6 +235,9 @@ export default { :suggestions="preloadedSuggestions" ></slot> <gl-loading-icon v-if="suggestionsLoading" size="sm" /> + <gl-dropdown-text v-else-if="showNoMatchesText"> + {{ __('No matches found') }} + </gl-dropdown-text> <template v-else> <slot name="suggestions-list" :suggestions="availableSuggestions"></slot> </template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue deleted file mode 100644 index 9c2f5306654..00000000000 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue +++ /dev/null @@ -1,129 +0,0 @@ -<script> -import { GlFilteredSearchSuggestion } from '@gitlab/ui'; -import createFlash from '~/flash'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { __ } from '~/locale'; -import { DEFAULT_NONE_ANY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants'; -import searchEpicsQuery from '../queries/search_epics.query.graphql'; - -import BaseToken from './base_token.vue'; - -export default { - prefix: '&', - separator: '::', - components: { - BaseToken, - GlFilteredSearchSuggestion, - }, - props: { - config: { - type: Object, - required: true, - }, - value: { - type: Object, - required: true, - }, - active: { - type: Boolean, - required: true, - }, - }, - data() { - return { - epics: this.config.initialEpics || [], - loading: false, - }; - }, - computed: { - idProperty() { - return this.config.idProperty || 'iid'; - }, - currentValue() { - const epicIid = Number(this.value.data); - if (epicIid) { - return epicIid; - } - return this.value.data; - }, - defaultEpics() { - return this.config.defaultEpics || DEFAULT_NONE_ANY; - }, - availableDefaultEpics() { - if (this.value.operator === OPERATOR_IS_NOT) { - return this.defaultEpics.filter( - (suggestion) => !FILTER_NONE_ANY.includes(suggestion.value), - ); - } - return this.defaultEpics; - }, - }, - methods: { - fetchEpics(search = '') { - return this.$apollo - .query({ - query: searchEpicsQuery, - variables: { fullPath: this.config.fullPath, search }, - }) - .then(({ data }) => data.group?.epics.nodes); - }, - fetchEpicsBySearchTerm(search) { - this.loading = true; - this.fetchEpics(search) - .then((response) => { - this.epics = Array.isArray(response) ? response : response?.data; - }) - .catch(() => createFlash({ message: __('There was a problem fetching epics.') })) - .finally(() => { - this.loading = false; - }); - }, - getActiveEpic(epics, data) { - if (data && epics.length) { - return epics.find((epic) => this.getValue(epic) === data); - } - return undefined; - }, - getValue(epic) { - return this.getEpicIdProperty(epic).toString(); - }, - displayValue(epic) { - return `${this.$options.prefix}${this.getEpicIdProperty(epic)}${this.$options.separator}${ - epic?.title - }`; - }, - getEpicIdProperty(epic) { - return getIdFromGraphQLId(epic[this.idProperty]); - }, - }, -}; -</script> - -<template> - <base-token - :config="config" - :value="value" - :active="active" - :suggestions-loading="loading" - :suggestions="epics" - :get-active-token-value="getActiveEpic" - :default-suggestions="availableDefaultEpics" - :recent-suggestions-storage-key="config.recentSuggestionsStorageKey" - search-by="title" - @fetch-suggestions="fetchEpicsBySearchTerm" - v-on="$listeners" - > - <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> - {{ activeTokenValue ? displayValue(activeTokenValue) : inputValue }} - </template> - <template #suggestions-list="{ suggestions }"> - <gl-filtered-search-suggestion - v-for="epic in suggestions" - :key="epic.id" - :value="getValue(epic)" - > - {{ epic.title }} - </gl-filtered-search-suggestion> - </template> - </base-token> -</template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue deleted file mode 100644 index aff93ebc9c0..00000000000 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue +++ /dev/null @@ -1,138 +0,0 @@ -<script> -import { GlDropdownDivider, GlDropdownSectionHeader, GlFilteredSearchSuggestion } from '@gitlab/ui'; -import createFlash from '~/flash'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { __ } from '~/locale'; -import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; -import { formatDate } from '~/lib/utils/datetime_utility'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { DEFAULT_ITERATIONS } from '../constants'; - -export default { - components: { - BaseToken, - GlDropdownDivider, - GlDropdownSectionHeader, - GlFilteredSearchSuggestion, - }, - mixins: [glFeatureFlagMixin()], - props: { - active: { - type: Boolean, - required: true, - }, - config: { - type: Object, - required: true, - }, - value: { - type: Object, - required: true, - }, - }, - data() { - return { - iterations: this.config.initialIterations || [], - loading: false, - }; - }, - computed: { - defaultIterations() { - return this.config.defaultIterations || DEFAULT_ITERATIONS; - }, - }, - methods: { - getActiveIteration(iterations, data) { - return iterations.find((iteration) => this.getValue(iteration) === data); - }, - groupIterationsByCadence(iterations) { - const cadences = []; - iterations.forEach((iteration) => { - if (!iteration.iterationCadence) { - return; - } - const { title } = iteration.iterationCadence; - const cadenceIteration = { - id: iteration.id, - title: iteration.title, - period: this.getIterationPeriod(iteration), - }; - const cadence = cadences.find((cad) => cad.title === title); - if (cadence) { - cadence.iterations.push(cadenceIteration); - } else { - cadences.push({ title, iterations: [cadenceIteration] }); - } - }); - return cadences; - }, - fetchIterations(searchTerm) { - this.loading = true; - this.config - .fetchIterations(searchTerm) - .then((response) => { - this.iterations = Array.isArray(response) ? response : response.data; - }) - .catch(() => { - createFlash({ message: __('There was a problem fetching iterations.') }); - }) - .finally(() => { - this.loading = false; - }); - }, - getValue(iteration) { - return String(getIdFromGraphQLId(iteration.id)); - }, - /** - * TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/344619 - * This method also exists as a utility function in ee/../iterations/utils.js - * Remove the duplication when iteration token is moved to EE. - */ - getIterationPeriod({ startDate, dueDate }) { - const start = formatDate(startDate, 'mmm d, yyyy', true); - const due = formatDate(dueDate, 'mmm d, yyyy', true); - return `${start} - ${due}`; - }, - }, -}; -</script> - -<template> - <base-token - :active="active" - :config="config" - :value="value" - :default-suggestions="defaultIterations" - :suggestions="iterations" - :suggestions-loading="loading" - :get-active-token-value="getActiveIteration" - @fetch-suggestions="fetchIterations" - v-on="$listeners" - > - <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> - {{ activeTokenValue ? activeTokenValue.title : inputValue }} - </template> - <template #suggestions-list="{ suggestions }"> - <template v-for="(cadence, index) in groupIterationsByCadence(suggestions)"> - <gl-dropdown-divider v-if="index !== 0" :key="index" /> - <gl-dropdown-section-header - :key="cadence.title" - class="gl-overflow-hidden" - :title="cadence.title" - > - {{ cadence.title }} - </gl-dropdown-section-header> - <gl-filtered-search-suggestion - v-for="iteration in cadence.iterations" - :key="iteration.id" - :value="getValue(iteration)" - > - {{ iteration.title }} - <div v-if="glFeatures.iterationCadences" class="gl-text-gray-400"> - {{ iteration.period }} - </div> - </gl-filtered-search-suggestion> - </template> - </template> - </base-token> -</template> 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 c31f3a25fb1..3f7a8920f48 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 @@ -104,7 +104,6 @@ export default { :suggestions="labels" :get-active-token-value="getActiveLabel" :default-suggestions="defaultLabels" - :recent-suggestions-storage-key="config.recentSuggestionsStorageKey" @fetch-suggestions="fetchLabels" v-on="$listeners" > diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue index 523438f459c..0d3394788fa 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue @@ -2,7 +2,7 @@ import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import createFlash from '~/flash'; import { __ } from '~/locale'; -import { sortMilestonesByDueDate } from '~/milestones/milestone_utils'; +import { sortMilestonesByDueDate } from '~/milestones/utils'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import { DEFAULT_MILESTONES } from '../constants'; import { stripQuotes } from '../filtered_search_utils'; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue deleted file mode 100644 index 280fb234576..00000000000 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue +++ /dev/null @@ -1,66 +0,0 @@ -<script> -import { GlFilteredSearchSuggestion } from '@gitlab/ui'; -import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; -import { DEFAULT_NONE_ANY, WEIGHT_TOKEN_SUGGESTIONS_SIZE } from '../constants'; - -const weights = Array.from(Array(WEIGHT_TOKEN_SUGGESTIONS_SIZE), (_, index) => index.toString()); - -export default { - components: { - BaseToken, - GlFilteredSearchSuggestion, - }, - props: { - active: { - type: Boolean, - required: true, - }, - config: { - type: Object, - required: true, - }, - value: { - type: Object, - required: true, - }, - }, - data() { - return { - weights, - }; - }, - computed: { - defaultWeights() { - return this.config.defaultWeights || DEFAULT_NONE_ANY; - }, - }, - methods: { - getActiveWeight(weightSuggestions, data) { - return weightSuggestions.find((weight) => weight === data); - }, - updateWeights(searchTerm) { - const weight = parseInt(searchTerm, 10); - this.weights = Number.isNaN(weight) ? weights : [String(weight)]; - }, - }, -}; -</script> - -<template> - <base-token - :active="active" - :config="config" - :value="value" - :default-suggestions="defaultWeights" - :suggestions="weights" - :get-active-token-value="getActiveWeight" - @fetch-suggestions="updateWeights" - v-on="$listeners" - > - <template #suggestions-list="{ suggestions }"> - <gl-filtered-search-suggestion v-for="weight of suggestions" :key="weight" :value="weight"> - {{ weight }} - </gl-filtered-search-suggestion> - </template> - </base-token> -</template> diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js new file mode 100644 index 00000000000..cdd7a074f34 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js @@ -0,0 +1,27 @@ +import InputCopyToggleVisibility from './input_copy_toggle_visibility.vue'; + +export default { + component: InputCopyToggleVisibility, + title: 'vue_shared/components/form/input_copy_toggle_visibility', +}; + +const defaultProps = { + value: 'hR8x1fuJbzwu5uFKLf9e', + formInputGroupProps: { class: 'gl-form-input-xl' }, +}; + +const Template = (args, { argTypes }) => ({ + components: { InputCopyToggleVisibility }, + props: Object.keys(argTypes), + template: `<input-copy-toggle-visibility + :value="value" + :initial-visibility="initialVisibility" + :show-toggle-visibility-button="showToggleVisibilityButton" + :show-copy-button="showCopyButton" + :form-input-group-props="formInputGroupProps" + :copy-button-title="copyButtonTitle" + />`, +}); + +export const Default = Template.bind({}); +Default.args = defaultProps; diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue new file mode 100644 index 00000000000..06949b59823 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue @@ -0,0 +1,127 @@ +<script> +import { GlFormInputGroup, GlFormGroup, GlButton, GlTooltipDirective } from '@gitlab/ui'; + +import { __ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +export default { + name: 'InputCopyToggleVisibility', + i18n: { + toggleVisibilityLabelHide: __('Click to hide'), + toggleVisibilityLabelReveal: __('Click to reveal'), + }, + components: { + GlFormInputGroup, + GlFormGroup, + GlButton, + ClipboardButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + value: { + type: String, + required: false, + default: '', + }, + initialVisibility: { + type: Boolean, + required: false, + default: false, + }, + showToggleVisibilityButton: { + type: Boolean, + required: false, + default: true, + }, + showCopyButton: { + type: Boolean, + required: false, + default: true, + }, + copyButtonTitle: { + type: String, + required: false, + default: __('Copy'), + }, + formInputGroupProps: { + type: Object, + required: false, + default() { + return {}; + }, + }, + }, + data() { + return { + valueIsVisible: this.initialVisibility, + }; + }, + computed: { + toggleVisibilityLabel() { + return this.valueIsVisible + ? this.$options.i18n.toggleVisibilityLabelHide + : this.$options.i18n.toggleVisibilityLabelReveal; + }, + toggleVisibilityIcon() { + return this.valueIsVisible ? 'eye-slash' : 'eye'; + }, + computedValueIsVisible() { + return !this.showToggleVisibilityButton || this.valueIsVisible; + }, + displayedValue() { + return this.computedValueIsVisible ? this.value : '*'.repeat(this.value.length || 20); + }, + }, + methods: { + handleToggleVisibilityButtonClick() { + this.valueIsVisible = !this.valueIsVisible; + + this.$emit('visibility-change', this.valueIsVisible); + }, + handleCopyButtonClick() { + this.$emit('copy'); + }, + handleFormInputCopy(event) { + if (this.computedValueIsVisible) { + return; + } + + event.clipboardData.setData('text/plain', this.value); + event.preventDefault(); + }, + }, +}; +</script> +<template> + <gl-form-group v-bind="$attrs"> + <gl-form-input-group + :value="displayedValue" + input-class="gl-font-monospace! gl-cursor-default!" + select-on-click + readonly + v-bind="formInputGroupProps" + @copy="handleFormInputCopy" + > + <template v-if="showToggleVisibilityButton || showCopyButton" #append> + <gl-button + v-if="showToggleVisibilityButton" + v-gl-tooltip.hover="toggleVisibilityLabel" + :aria-label="toggleVisibilityLabel" + :icon="toggleVisibilityIcon" + @click="handleToggleVisibilityButtonClick" + /> + <clipboard-button + v-if="showCopyButton" + :text="value" + :title="copyButtonTitle" + @click="handleCopyButtonClick" + /> + </template> + </gl-form-input-group> + <template v-for="slot in Object.keys($slots)" #[slot]> + <slot :name="slot"></slot> + </template> + </gl-form-group> +</template> 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 6ace0bd88f8..9bff469b670 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -5,6 +5,7 @@ import { GlSafeHtmlDirective, GlAvatarLink, GlAvatarLabeled, + GlTooltip, } from '@gitlab/ui'; import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { glEmojiTag } from '../../emoji'; @@ -26,6 +27,7 @@ export default { GlButton, GlAvatarLink, GlAvatarLabeled, + GlTooltip, }, directives: { GlTooltip: GlTooltipDirective, diff --git a/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js b/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js deleted file mode 100644 index 28aa93d6680..00000000000 --- a/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js +++ /dev/null @@ -1,22 +0,0 @@ -import Vue from 'vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import IssuableHeaderWarnings from './issuable_header_warnings.vue'; - -export default function issuableHeaderWarnings(store) { - const el = document.getElementById('js-issuable-header-warnings'); - - if (!el) { - return false; - } - - const { hidden } = el.dataset; - - return new Vue({ - el, - store, - provide: { hidden: parseBoolean(hidden) }, - render(createElement) { - return createElement(IssuableHeaderWarnings); - }, - }); -} diff --git a/app/assets/javascripts/vue_shared/components/line_numbers.vue b/app/assets/javascripts/vue_shared/components/line_numbers.vue new file mode 100644 index 00000000000..7e17cca3dcc --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/line_numbers.vue @@ -0,0 +1,57 @@ +<script> +import { GlIcon, GlLink } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + GlLink, + }, + props: { + lines: { + type: Number, + required: true, + }, + }, + data() { + return { + currentlyHighlightedLine: null, + }; + }, + mounted() { + this.scrollToLine(); + }, + methods: { + scrollToLine(hash = window.location.hash) { + const lineToHighlight = hash && this.$el.querySelector(hash); + + if (!lineToHighlight) { + return; + } + + if (this.currentlyHighlightedLine) { + this.currentlyHighlightedLine.classList.remove('hll'); + } + + lineToHighlight.classList.add('hll'); + this.currentlyHighlightedLine = lineToHighlight; + lineToHighlight.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, + }, +}; +</script> +<template> + <div class="line-numbers"> + <gl-link + v-for="line in lines" + :id="`L${line}`" + :key="line" + class="diff-line-num" + :href="`#L${line}`" + :data-line-number="line" + @click="scrollToLine(`#L${line}`)" + > + <gl-icon :size="12" name="link" /> + {{ line }} + </gl-link> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index e36cfb3b275..2f6776f835e 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -165,6 +165,6 @@ export default { <template> <div> <div class="flash-container js-suggestions-flash"></div> - <div v-show="isRendered" ref="container" v-safe-html="noteHtml" class="md"></div> + <div v-show="isRendered" ref="container" v-safe-html="noteHtml" class="md suggestions"></div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index 912aa8ce294..f1c293c87f4 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -1,18 +1,13 @@ <script> import { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon } from '@gitlab/ui'; -import { isExperimentVariant } from '~/experimentation/utils'; -import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; -import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants'; export default { - inviteMembersInComment: INVITE_MEMBERS_IN_COMMENT, components: { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon, - InviteMembersTrigger, }, props: { markdownDocsPath: { @@ -34,9 +29,6 @@ export default { hasQuickActionsDocsPath() { return this.quickActionsDocsPath !== ''; }, - inviteCommentEnabled() { - return isExperimentVariant(INVITE_MEMBERS_IN_COMMENT, 'invite_member_link'); - }, }, }; </script> @@ -67,16 +59,6 @@ export default { </template> </div> <span v-if="canAttachFile" class="uploading-container"> - <invite-members-trigger - v-if="inviteCommentEnabled" - classes="gl-mr-3 gl-vertical-align-text-bottom" - :display-text="s__('InviteMember|Invite Member')" - icon="assignee" - variant="link" - :track-experiment="$options.inviteMembersInComment" - :trigger-source="$options.inviteMembersInComment" - data-track-action="comment_invite_click" - /> <span class="uploading-progress-container hide"> <gl-icon name="media" /> <span class="attaching-file-message"></span> diff --git a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue new file mode 100644 index 00000000000..7d2af7983d1 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue @@ -0,0 +1,93 @@ +<script> +import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export const i18n = { + DEFAULT_TEXT: __('Select a new namespace'), + GROUPS: __('Groups'), + USERS: __('Users'), +}; + +const filterByName = (data, searchTerm = '') => + data.filter((d) => d.humanName.toLowerCase().includes(searchTerm)); + +export default { + name: 'NamespaceSelect', + components: { + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlSearchBoxByType, + }, + props: { + data: { + type: Object, + required: true, + }, + fullWidth: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + searchTerm: '', + selectedNamespace: null, + }; + }, + computed: { + hasUserNamespaces() { + return this.data.user?.length; + }, + hasGroupNamespaces() { + return this.data.group?.length; + }, + filteredGroupNamespaces() { + if (!this.hasGroupNamespaces) return []; + return filterByName(this.data.group, this.searchTerm); + }, + filteredUserNamespaces() { + if (!this.hasUserNamespaces) return []; + return filterByName(this.data.user, this.searchTerm); + }, + selectedNamespaceText() { + return this.selectedNamespace?.humanName || this.$options.i18n.DEFAULT_TEXT; + }, + }, + methods: { + handleSelect(item) { + this.selectedNamespace = item; + this.$emit('select', item); + }, + }, + i18n, +}; +</script> +<template> + <gl-dropdown :text="selectedNamespaceText" :block="fullWidth"> + <template #header> + <gl-search-box-by-type v-model.trim="searchTerm" /> + </template> + <div v-if="hasGroupNamespaces" class="qa-namespaces-list-groups"> + <gl-dropdown-section-header>{{ $options.i18n.GROUPS }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="item in filteredGroupNamespaces" + :key="item.id" + class="qa-namespaces-list-item" + @click="handleSelect(item)" + >{{ item.humanName }}</gl-dropdown-item + > + </div> + <div v-if="hasUserNamespaces" class="qa-namespaces-list-users"> + <gl-dropdown-section-header>{{ $options.i18n.USERS }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="item in filteredUserNamespaces" + :key="item.id" + class="qa-namespaces-list-item" + @click="handleSelect(item)" + >{{ item.humanName }}</gl-dropdown-item + > + </div> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue index 9ea14ed506c..624dbcc6d8e 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue @@ -39,6 +39,11 @@ export default { required: false, default: null, }, + isOverviewTab: { + type: Boolean, + required: false, + default: false, + }, }, computed: { ...mapGetters(['getUserData']), @@ -46,9 +51,10 @@ export default { return renderMarkdown(this.note.body); }, avatarSize() { - if (this.line) { - return 16; + if (this.line && !this.isOverviewTab) { + return 24; } + return 40; }, }, diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index 8877cfa39fb..1963d1aa7fe 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -141,6 +141,7 @@ export default { variant="link" :icon="descriptionVersionToggleIcon" data-testid="compare-btn" + class="gl-vertical-align-text-bottom" @click="toggleDescriptionVersion" >{{ __('Compare with previous version') }}</gl-button > @@ -149,6 +150,7 @@ export default { :icon="showLines ? 'chevron-up' : 'chevron-down'" variant="link" data-testid="outdated-lines-change-btn" + class="gl-vertical-align-text-bottom" @click="toggleDiff" > {{ __('Compare changes') }} diff --git a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js new file mode 100644 index 00000000000..e31446f4bb8 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js @@ -0,0 +1,40 @@ +/* eslint-disable @gitlab/require-i18n-strings */ +import PaginationBar from './pagination_bar.vue'; + +export default { + component: PaginationBar, + title: 'vue_shared/components/pagination_bar/pagination_bar', +}; + +const Template = (args, { argTypes }) => ({ + components: { PaginationBar }, + props: Object.keys(argTypes), + template: `<pagination-bar v-bind="$props" v-on="{ 'set-page-size': setPageSize, 'set-page': setPage }" />`, +}); + +export const Default = Template.bind({}); + +Default.args = { + pageInfo: { + perPage: 20, + page: 2, + total: 83, + totalPages: 5, + }, + pageSizes: [20, 50, 100], +}; + +Default.argTypes = { + pageInfo: { + description: 'Page info object', + control: { type: 'object' }, + }, + pageSizes: { + description: 'Array of possible page sizes', + control: { type: 'array' }, + }, + + // events + setPageSize: { action: 'set-page-size' }, + setPage: { action: 'set-page' }, +}; diff --git a/app/assets/javascripts/import_entities/components/pagination_bar.vue b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue index 33bd3e08bb1..b4d565991f5 100644 --- a/app/assets/javascripts/import_entities/components/pagination_bar.vue +++ b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue @@ -23,10 +23,6 @@ export default { type: Array, default: () => DEFAULT_PAGE_SIZES, }, - itemsCount: { - required: true, - type: Number, - }, }, computed: { @@ -35,9 +31,10 @@ export default { }, paginationInfo() { - const { page, perPage } = this.pageInfo; + const { page, perPage, totalPages, total } = this.pageInfo; + const itemsCount = page === totalPages ? total - (page - 1) * perPage : perPage; const start = (page - 1) * perPage + 1; - const end = start + this.itemsCount - 1; + const end = start + itemsCount - 1; return { start, end }; }, @@ -45,8 +42,24 @@ export default { methods: { setPage(page) { + // eslint-disable-next-line spaced-comment + /** + * Emitted when selected page is updated + * + * @event set-page + **/ this.$emit('set-page', page); }, + + setPageSize(pageSize) { + // eslint-disable-next-line spaced-comment + /** + * Emitted when page size is updated + * + * @event set-page-size + **/ + this.$emit('set-page-size', pageSize); + }, }, }; </script> @@ -54,7 +67,7 @@ export default { <template> <div class="gl-display-flex gl-align-items-center"> <pagination-links :change="setPage" :page-info="pageInfo" class="gl-m-0" /> - <gl-dropdown category="tertiary" class="gl-ml-auto"> + <gl-dropdown category="tertiary" class="gl-ml-auto" data-testid="page-size"> <template #button-content> <span class="gl-font-weight-bold"> <gl-sprintf :message="__('%{count} items per page')"> @@ -65,7 +78,7 @@ export default { </span> <gl-icon class="gl-button-icon dropdown-chevron" name="chevron-down" /> </template> - <gl-dropdown-item v-for="size in pageSizes" :key="size" @click="$emit('set-page-size', size)"> + <gl-dropdown-item v-for="size in pageSizes" :key="size" @click="setPageSize(size)"> <gl-sprintf :message="__('%{count} items per page')"> <template #count> {{ size }} 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 933a215112b..6bb321713d5 100644 --- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue +++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue @@ -54,10 +54,10 @@ export default { class="gl-display-flex gl-flex-direction-column gl-border-b-solid gl-border-t-solid gl-border-t-1 gl-border-b-1" :class="optionalClasses" > - <div class="gl-display-flex gl-align-items-center gl-py-3 gl-px-5"> + <div class="gl-display-flex gl-align-items-center gl-py-3"> <div v-if="$slots['left-action']" - class="gl-w-7 gl-display-none gl-sm-display-flex gl-justify-content-start gl-pl-2" + class="gl-w-7 gl-display-flex gl-justify-content-start gl-pl-2" > <slot name="left-action"></slot> </div> @@ -105,7 +105,7 @@ export default { </div> <div v-if="$slots['right-action']" - class="gl-w-9 gl-display-none gl-sm-display-flex gl-justify-content-end gl-pr-1" + class="gl-w-9 gl-display-flex gl-justify-content-end gl-pr-1" > <slot name="right-action"></slot> </div> diff --git a/app/assets/javascripts/vue_shared/components/registry/metadata_item.vue b/app/assets/javascripts/vue_shared/components/registry/metadata_item.vue index 93396219a54..4c2816b63b2 100644 --- a/app/assets/javascripts/vue_shared/components/registry/metadata_item.vue +++ b/app/assets/javascripts/vue_shared/components/registry/metadata_item.vue @@ -1,6 +1,6 @@ <script> import { GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; export default { name: 'MetadataItem', diff --git a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue deleted file mode 100644 index a1dca65a423..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue +++ /dev/null @@ -1,101 +0,0 @@ -<script> -import { dateInWords, timeFor } from '~/lib/utils/datetime_utility'; -import { __ } from '~/locale'; -import timeagoMixin from '~/vue_shared/mixins/timeago'; -import collapsedCalendarIcon from './collapsed_calendar_icon.vue'; - -export default { - name: 'SidebarCollapsedGroupedDatePicker', - components: { - collapsedCalendarIcon, - }, - mixins: [timeagoMixin], - props: { - collapsed: { - type: Boolean, - required: false, - default: true, - }, - minDate: { - type: Date, - required: false, - default: null, - }, - maxDate: { - type: Date, - required: false, - default: null, - }, - disableClickableIcons: { - type: Boolean, - required: false, - default: false, - }, - }, - computed: { - hasMinAndMaxDates() { - return this.minDate && this.maxDate; - }, - hasNoMinAndMaxDates() { - return !this.minDate && !this.maxDate; - }, - showMinDateBlock() { - return this.minDate || this.hasNoMinAndMaxDates; - }, - showFromText() { - return !this.maxDate && this.minDate; - }, - iconClass() { - const disabledClass = this.disableClickableIcons ? 'disabled' : ''; - return `sidebar-collapsed-icon calendar-icon ${disabledClass}`; - }, - }, - methods: { - toggleSidebar() { - this.$emit('toggleCollapse'); - }, - dateText(dateType = 'min') { - const date = this[`${dateType}Date`]; - const dateWords = dateInWords(date, true); - const parsedDateWords = dateWords ? dateWords.replace(',', '') : dateWords; - - return date ? parsedDateWords : __('None'); - }, - tooltipText(dateType = 'min') { - const defaultText = dateType === 'min' ? __('Start date') : __('Due date'); - const date = this[`${dateType}Date`]; - const timeAgo = dateType === 'min' ? this.timeFormatted(date) : timeFor(date); - const dateText = date ? [this.dateText(dateType), `(${timeAgo})`].join(' ') : ''; - - if (date) { - return [defaultText, dateText].join('<br />'); - } - return __('Start and due date'); - }, - }, -}; -</script> - -<template> - <div class="block sidebar-grouped-item gl-cursor-pointer" role="button" @click="toggleSidebar"> - <collapsed-calendar-icon - v-if="showMinDateBlock" - :container-class="iconClass" - :tooltip-text="tooltipText('min')" - > - <span class="sidebar-collapsed-value"> - <span v-if="showFromText">{{ __('From') }}</span> <span>{{ dateText('min') }}</span> - </span> - </collapsed-calendar-icon> - <div v-if="hasMinAndMaxDates" class="text-center sidebar-collapsed-divider">-</div> - <collapsed-calendar-icon - v-if="maxDate" - :container-class="iconClass" - :tooltip-text="tooltipText('max')" - > - <span class="sidebar-collapsed-value"> - <span v-if="!minDate">{{ __('Until') }}</span> <span>{{ dateText('max') }}</span> - </span> - </collapsed-calendar-icon> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue index 4234bc72f3a..7e259cb8b96 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue @@ -179,6 +179,8 @@ export default { document.addEventListener('mousedown', this.handleDocumentMousedown); document.addEventListener('click', this.handleDocumentClick); + + this.updateLabelsSetState(); }, beforeDestroy() { document.removeEventListener('mousedown', this.handleDocumentMousedown); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue index f7485de0342..13a6dd43207 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue @@ -172,6 +172,13 @@ export default { showDropdown() { this.$refs.dropdown.show(); }, + clearSearch() { + if (!this.allowMultiselect || this.isStandalone) { + return; + } + this.searchKey = ''; + this.setFocus(); + }, }, }; </script> @@ -188,12 +195,12 @@ export default { > <template #header> <dropdown-header - v-if="!isStandalone" ref="header" - v-model="searchKey" + :search-key="searchKey" :labels-create-title="labelsCreateTitle" :labels-list-title="labelsListTitle" :show-dropdown-contents-create-view="showDropdownContentsCreateView" + :is-standalone="isStandalone" @toggleDropdownContentsCreateView="toggleDropdownContent" @closeDropdown="$emit('closeDropdown')" @input="debouncedSearchKeyUpdate" @@ -210,6 +217,7 @@ export default { :attr-workspace-path="attrWorkspacePath" :label-create-type="labelCreateType" @hideCreateView="toggleDropdownContent" + @input="clearSearch" /> </template> <template #footer> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue index 10064b01648..7a0f20b0c83 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue @@ -6,9 +6,6 @@ export default { GlButton, GlSearchBoxByType, }, - model: { - prop: 'searchKey', - }, props: { labelsCreateTitle: { type: String, @@ -31,6 +28,11 @@ export default { type: String, required: true, }, + isStandalone: { + type: Boolean, + required: false, + default: false, + }, }, computed: { dropdownTitle() { @@ -47,7 +49,11 @@ export default { <template> <div data-testid="dropdown-header"> - <div class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"> + <div + v-if="!isStandalone" + class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!" + data-testid="dropdown-header-title" + > <gl-button v-if="showDropdownContentsCreateView" :aria-label="__('Go back')" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue index aed5bc303ee..57ee816c4c7 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue @@ -1,10 +1,15 @@ <script> -import { GlLabel } from '@gitlab/ui'; +import { GlIcon, GlLabel, GlTooltipDirective } from '@gitlab/ui'; import { sortBy } from 'lodash'; import { isScopedLabel } from '~/lib/utils/common_utils'; +import { s__, sprintf } from '~/locale'; export default { + directives: { + GlTooltip: GlTooltipDirective, + }, components: { + GlIcon, GlLabel, }, inject: ['allowScopedLabels'], @@ -35,6 +40,23 @@ export default { sortedSelectedLabels() { return sortBy(this.selectedLabels, (label) => (isScopedLabel(label) ? 0 : 1)); }, + labelsList() { + const labelsString = this.selectedLabels.length + ? this.selectedLabels + .slice(0, 5) + .map((label) => label.title) + .join(', ') + : s__('LabelSelect|Labels'); + + if (this.selectedLabels.length > 5) { + return sprintf(s__('LabelSelect|%{labelsString}, and %{remainingLabelCount} more'), { + labelsString, + remainingLabelCount: this.selectedLabels.length - 5, + }); + } + + return labelsString; + }, }, methods: { labelFilterUrl(label) { @@ -48,6 +70,9 @@ export default { removeLabel(labelId) { this.$emit('onLabelRemove', labelId); }, + handleCollapsedClick() { + this.$emit('onCollapsedValueClick'); + }, }, }; </script> @@ -57,16 +82,30 @@ export default { :class="{ 'has-labels': selectedLabels.length, }" - class="hide-collapsed value issuable-show-labels js-value" + class="value issuable-show-labels js-value" data-testid="value-wrapper" > - <span v-if="!selectedLabels.length" class="text-secondary" data-testid="empty-placeholder"> + <div + v-gl-tooltip.left.viewport + :title="labelsList" + class="sidebar-collapsed-icon" + @click="handleCollapsedClick" + > + <gl-icon name="labels" /> + <span class="gl-font-base gl-line-height-24">{{ selectedLabels.length }}</span> + </div> + <span + v-if="!selectedLabels.length" + class="text-secondary hide-collapsed" + data-testid="empty-placeholder" + > <slot></slot> </span> <template v-else> <gl-label v-for="label in sortedSelectedLabels" :key="label.id" + class="hide-collapsed" data-qa-selector="selected_label_content" :data-qa-label-name="label.title" :title="label.title" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue deleted file mode 100644 index 122250d1ce7..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue +++ /dev/null @@ -1,55 +0,0 @@ -<script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { s__, sprintf } from '~/locale'; - -export default { - directives: { - GlTooltip: GlTooltipDirective, - }, - components: { - GlIcon, - }, - props: { - labels: { - type: Array, - required: true, - }, - }, - computed: { - labelsList() { - const labelsString = this.labels.length - ? this.labels - .slice(0, 5) - .map((label) => label.title) - .join(', ') - : s__('LabelSelect|Labels'); - - if (this.labels.length > 5) { - return sprintf(s__('LabelSelect|%{labelsString}, and %{remainingLabelCount} more'), { - labelsString, - remainingLabelCount: this.labels.length - 5, - }); - } - - return labelsString; - }, - }, - methods: { - handleClick() { - this.$emit('onValueClick'); - }, - }, -}; -</script> - -<template> - <div - v-gl-tooltip.left.viewport - :title="labelsList" - class="sidebar-collapsed-icon" - @click="handleClick" - > - <gl-icon name="labels" /> - <span>{{ labels.length }}</span> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql index c130cc426dc..c442c17eb88 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql @@ -2,6 +2,7 @@ query epicLabels($fullPath: ID!, $iid: ID) { workspace: group(fullPath: $fullPath) { + id issuable: epic(iid: $iid) { id labels { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql index 45fcb50732e..cb054e2968f 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql @@ -1,8 +1,8 @@ #import "~/graphql_shared/fragments/label.fragment.graphql" mutation updateEpicLabels($input: UpdateEpicInput!) { - updateEpic(input: $input) { - epic { + updateIssuableLabels: updateEpic(input: $input) { + issuable: epic { id labels { nodes { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql index e471d279b24..2904857270e 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql @@ -2,6 +2,7 @@ query issueLabels($fullPath: ID!, $iid: String) { workspace: project(fullPath: $fullPath) { + id issuable: issue(iid: $iid) { id labels { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql index dd80e89c8a7..e0cdfd91658 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql @@ -2,6 +2,7 @@ query mergeRequestLabels($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { + id issuable: mergeRequest(iid: $iid) { id labels { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue index 97a65c13933..3adda69b892 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue @@ -2,14 +2,13 @@ import { debounce } from 'lodash'; import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils'; import createFlash from '~/flash'; -import { IssuableType } from '~/issue_show/constants'; +import { IssuableType } from '~/issues/constants'; import { __ } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import { issuableLabelsQueries } from '~/sidebar/constants'; import { DEBOUNCE_DROPDOWN_DELAY, DropdownVariant } from './constants'; import DropdownContents from './dropdown_contents.vue'; import DropdownValue from './dropdown_value.vue'; -import DropdownValueCollapsed from './dropdown_value_collapsed.vue'; import { isDropdownVariantSidebar, isDropdownVariantStandalone, @@ -20,7 +19,6 @@ export default { components: { DropdownValue, DropdownContents, - DropdownValueCollapsed, SidebarEditableItem, }, inject: { @@ -225,15 +223,13 @@ export default { variables: { input: inputVariables }, }) .then(({ data }) => { - const { mutationName } = issuableLabelsQueries[this.issuableType]; - - if (data[mutationName]?.errors?.length) { + if (data.updateIssuableLabels?.errors?.length) { throw new Error(); } this.$emit('updateSelectedLabels', { - id: data[mutationName]?.[this.issuableType]?.id, - labels: data[mutationName]?.[this.issuableType]?.labels?.nodes, + id: data.updateIssuableLabels?.issuable?.id, + labels: data.updateIssuableLabels?.issuable?.labels?.nodes, }); }) .catch((error) => @@ -288,18 +284,14 @@ export default { <template> <div - class="labels-select-wrapper position-relative" + class="labels-select-wrapper gl-relative" :class="{ 'is-standalone': isDropdownVariantStandalone(variant), 'is-embedded': isDropdownVariantEmbedded(variant), }" + data-qa-selector="labels_block" > <template v-if="isDropdownVariantSidebar(variant)"> - <dropdown-value-collapsed - ref="dropdownButtonCollapsed" - :labels="issuableLabels" - @onValueClick="handleCollapsedValueClick" - /> <sidebar-editable-item ref="editable" :title="__('Labels')" @@ -315,6 +307,7 @@ export default { :labels-filter-base-path="labelsFilterBasePath" :labels-filter-param="labelsFilterParam" @onLabelRemove="handleLabelRemove" + @onCollapsedValueClick="handleCollapsedValueClick" > <slot></slot> </dropdown-value> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql index d99fc125012..bb6c7181e5c 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql @@ -7,6 +7,7 @@ query alertAssignees( $iid: String! ) { workspace: project(fullPath: $fullPath) { + id issuable: alertManagementAlert(domain: $domain, iid: $iid) { iid assignees { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql index 93b9833bb7d..be270e440ed 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql @@ -4,6 +4,7 @@ query issueAssignees($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { __typename + id issuable: issue(iid: $iid) { __typename id diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql index 48787305459..96a40e597ee 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql @@ -4,6 +4,7 @@ query issueParticipants($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { __typename + id issuable: issue(iid: $iid) { __typename id diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql index 53f7381760e..81e19e48d75 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql @@ -3,6 +3,7 @@ query getMrAssignees($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { + id issuable: mergeRequest(iid: $iid) { id assignees { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql index 6adbd4098f2..3496d5f4a2e 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql @@ -3,6 +3,7 @@ query getMrParticipants($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { + id issuable: mergeRequest(iid: $iid) { id participants { diff --git a/app/assets/javascripts/vue_shared/components/source_editor.vue b/app/assets/javascripts/vue_shared/components/source_editor.vue index fdf0c9baee3..8a0fef36079 100644 --- a/app/assets/javascripts/vue_shared/components/source_editor.vue +++ b/app/assets/javascripts/vue_shared/components/source_editor.vue @@ -96,6 +96,7 @@ export default { :id="`source-editor-${fileGlobalId}`" ref="editor" data-editor-loading + data-qa-selector="source_editor_container" @[$options.readyEvent]="$emit($options.readyEvent)" > <pre class="editor-loading-content">{{ value }}</pre> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer.vue new file mode 100644 index 00000000000..8f0d051543f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer.vue @@ -0,0 +1,88 @@ +<script> +import { GlSafeHtmlDirective } from '@gitlab/ui'; +import LineNumbers from '~/vue_shared/components/line_numbers.vue'; + +export default { + components: { + LineNumbers, + }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, + props: { + content: { + type: String, + required: true, + }, + language: { + type: String, + required: false, + default: 'plaintext', + }, + autoDetect: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + languageDefinition: null, + hljs: null, + }; + }, + computed: { + lineNumbers() { + return this.content.split('\n').length; + }, + highlightedContent() { + let highlightedContent; + + if (this.hljs) { + if (this.autoDetect) { + highlightedContent = this.hljs.highlightAuto(this.content).value; + } else if (this.languageDefinition) { + highlightedContent = this.hljs.highlight(this.content, { language: this.language }).value; + } + } + + return highlightedContent; + }, + }, + async mounted() { + this.hljs = await this.loadHighlightJS(); + + if (!this.autoDetect) { + this.languageDefinition = await this.loadLanguage(); + } + }, + methods: { + loadHighlightJS() { + // With auto-detect enabled we load all common languages else we load only the core (smallest footprint) + return this.autoDetect ? import('highlight.js/lib/common') : import('highlight.js/lib/core'); + }, + async loadLanguage() { + let languageDefinition; + + try { + languageDefinition = await import(`highlight.js/lib/languages/${this.language}`); + this.hljs.registerLanguage(this.language, languageDefinition.default); + } catch (message) { + this.$emit('error', message); + } + + return languageDefinition; + }, + }, + userColorScheme: window.gon.user_color_scheme, +}; +</script> +<template> + <div class="file-content code" :class="$options.userColorScheme"> + <line-numbers :lines="lineNumbers" /> + <pre + class="code gl-pl-3!" + ><code v-safe-html="highlightedContent" class="gl-white-space-pre-wrap!"></code> + </pre> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.stories.js b/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.stories.js deleted file mode 100644 index 00aa5519ec6..00000000000 --- a/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.stories.js +++ /dev/null @@ -1,38 +0,0 @@ -/* eslint-disable @gitlab/require-i18n-strings */ -import '@gitlab/ui/dist/utility_classes.css'; -import UsageGraph from './usage_graph.vue'; - -export default { - component: UsageGraph, - title: 'vue_shared/components/storage_counter/usage_graph', -}; - -const Template = (args, { argTypes }) => ({ - components: { UsageGraph }, - props: Object.keys(argTypes), - template: '<usage-graph v-bind="$props" />', -}); - -export const Default = Template.bind({}); -Default.argTypes = { - rootStorageStatistics: { - description: 'The statistics object with all its fields', - type: { name: 'object', required: true }, - defaultValue: { - buildArtifactsSize: 400000, - pipelineArtifactsSize: 38000, - lfsObjectsSize: 4800000, - packagesSize: 3800000, - repositorySize: 39000000, - snippetsSize: 2000112, - storageSize: 39930000, - uploadsSize: 7000, - wikiSize: 300000, - }, - }, - limit: { - description: - 'When a limit is set, users will see how much of their storage usage (limit) is used. In case the limit is 0 or the current usage exceeds the limit, it just renders the distribution', - defaultValue: 0, - }, -}; diff --git a/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.vue b/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.vue deleted file mode 100644 index c33d065ff4b..00000000000 --- a/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.vue +++ /dev/null @@ -1,148 +0,0 @@ -<script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { numberToHumanSize } from '~/lib/utils/number_utils'; -import { s__ } from '~/locale'; - -export default { - components: { - GlIcon, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - rootStorageStatistics: { - required: true, - type: Object, - }, - limit: { - required: true, - type: Number, - }, - }, - computed: { - storageTypes() { - const { - buildArtifactsSize, - pipelineArtifactsSize, - lfsObjectsSize, - packagesSize, - repositorySize, - storageSize, - wikiSize, - snippetsSize, - uploadsSize, - } = this.rootStorageStatistics; - const artifactsSize = buildArtifactsSize + pipelineArtifactsSize; - - if (storageSize === 0) { - return null; - } - - return [ - { - name: s__('UsageQuota|Repositories'), - style: this.usageStyle(this.barRatio(repositorySize)), - class: 'gl-bg-data-viz-blue-500', - size: repositorySize, - }, - { - name: s__('UsageQuota|LFS Objects'), - style: this.usageStyle(this.barRatio(lfsObjectsSize)), - class: 'gl-bg-data-viz-orange-600', - size: lfsObjectsSize, - }, - { - name: s__('UsageQuota|Packages'), - style: this.usageStyle(this.barRatio(packagesSize)), - class: 'gl-bg-data-viz-aqua-500', - size: packagesSize, - }, - { - name: s__('UsageQuota|Artifacts'), - style: this.usageStyle(this.barRatio(artifactsSize)), - class: 'gl-bg-data-viz-green-600', - size: artifactsSize, - tooltip: s__('UsageQuota|Artifacts is a sum of build and pipeline artifacts.'), - }, - { - name: s__('UsageQuota|Wikis'), - style: this.usageStyle(this.barRatio(wikiSize)), - class: 'gl-bg-data-viz-magenta-500', - size: wikiSize, - }, - { - name: s__('UsageQuota|Snippets'), - style: this.usageStyle(this.barRatio(snippetsSize)), - class: 'gl-bg-data-viz-orange-800', - size: snippetsSize, - }, - { - name: s__('UsageQuota|Uploads'), - style: this.usageStyle(this.barRatio(uploadsSize)), - class: 'gl-bg-data-viz-aqua-700', - size: uploadsSize, - }, - ] - .filter((data) => data.size !== 0) - .sort((a, b) => b.size - a.size); - }, - }, - methods: { - formatSize(size) { - return numberToHumanSize(size); - }, - usageStyle(ratio) { - return { flex: ratio }; - }, - barRatio(size) { - let max = this.rootStorageStatistics.storageSize; - - if (this.limit !== 0 && max <= this.limit) { - max = this.limit; - } - - return size / max; - }, - }, -}; -</script> -<template> - <div v-if="storageTypes" class="gl-display-flex gl-flex-direction-column w-100"> - <div class="gl-h-6 gl-my-5 gl-bg-gray-50 gl-rounded-base gl-display-flex"> - <div - v-for="storageType in storageTypes" - :key="storageType.name" - class="storage-type-usage gl-h-full gl-display-inline-block" - :class="storageType.class" - :style="storageType.style" - data-testid="storage-type-usage" - ></div> - </div> - <div class="row py-0"> - <div - v-for="storageType in storageTypes" - :key="storageType.name" - class="col-md-auto gl-display-flex gl-align-items-center" - data-testid="storage-type-legend" - > - <div class="gl-h-2 gl-w-5 gl-mr-2 gl-display-inline-block" :class="storageType.class"></div> - <span class="gl-mr-2 gl-font-weight-bold gl-font-sm"> - {{ storageType.name }} - </span> - <span class="gl-text-gray-500 gl-font-sm"> - {{ formatSize(storageType.size) }} - </span> - <span - v-if="storageType.tooltip" - v-gl-tooltip - :title="storageType.tooltip" - :aria-label="storageType.tooltip" - class="gl-ml-2" - > - <gl-icon name="question" :size="12" /> - </span> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue deleted file mode 100644 index c5fdb5fc242..00000000000 --- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue +++ /dev/null @@ -1,69 +0,0 @@ -<script> -import { GlTooltipDirective as GlTooltip } from '@gitlab/ui'; -import { isFunction } from 'lodash'; -import { hasHorizontalOverflow } from '~/lib/utils/dom_utils'; - -export default { - directives: { - GlTooltip, - }, - props: { - title: { - type: String, - required: false, - default: '', - }, - placement: { - type: String, - required: false, - default: 'top', - }, - truncateTarget: { - type: [String, Function], - required: false, - default: '', - }, - }, - data() { - return { - showTooltip: false, - }; - }, - watch: { - title() { - // Wait on $nextTick in case of slot width changes - this.$nextTick(this.updateTooltip); - }, - }, - mounted() { - this.updateTooltip(); - }, - methods: { - selectTarget() { - if (isFunction(this.truncateTarget)) { - return this.truncateTarget(this.$el); - } else if (this.truncateTarget === 'child') { - return this.$el.childNodes[0]; - } - - return this.$el; - }, - updateTooltip() { - const target = this.selectTarget(); - this.showTooltip = hasHorizontalOverflow(target); - }, - }, -}; -</script> - -<template> - <span - v-if="showTooltip" - v-gl-tooltip="{ placement }" - :title="title" - class="js-show-tooltip gl-min-w-0" - > - <slot></slot> - </span> - <span v-else class="gl-min-w-0"> <slot></slot> </span> -</template> diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js new file mode 100644 index 00000000000..f27901a30a9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js @@ -0,0 +1,88 @@ +/* eslint-disable @gitlab/require-i18n-strings */ +import TooltipOnTruncate from './tooltip_on_truncate.vue'; + +const defaultWidth = '250px'; + +export default { + component: TooltipOnTruncate, + title: 'vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue', +}; + +const createStory = ({ ...options }) => { + return (_, { argTypes }) => { + const comp = { + components: { TooltipOnTruncate }, + props: Object.keys(argTypes), + template: ` + <div class="gl-bg-blue-50" :style="{ width }"> + <tooltip-on-truncate :title="title" :placement="placement" class="gl-display-block gl-text-truncate"> + {{title}} + </tooltip-on-truncate> + </div> + `, + ...options, + }; + + return comp; + }; +}; + +export const Default = createStory(); +Default.args = { + width: defaultWidth, + title: 'Hover on this text to see the content in a tooltip.', +}; + +export const NoOverflow = createStory(); +NoOverflow.args = { + width: defaultWidth, + title: "Short text doesn't need a tooltip.", +}; + +export const Placement = createStory(); +Placement.args = { + width: defaultWidth, + title: 'Use `placement="right"` to display this tooltip at the right.', + placement: 'right', +}; + +const TIMEOUT_S = 3; + +export const LiveUpdates = createStory({ + props: ['width', 'placement'], + data() { + return { + title: `(loading in ${TIMEOUT_S}s)`, + }; + }, + mounted() { + setTimeout(() => { + this.title = 'Content updated! The content is now overflowing so we use a tooltip!'; + }, TIMEOUT_S * 1000); + }, +}); +LiveUpdates.args = { + width: defaultWidth, +}; +LiveUpdates.argTypes = { + title: { + control: false, + }, +}; + +export const TruncateTarget = createStory({ + template: ` + <div class="gl-bg-black" :style="{ width }"> + <tooltip-on-truncate class="gl-display-flex" :truncate-target="truncateTarget" :title="title"> + <div class="gl-m-5 gl-bg-blue-50 gl-text-truncate"> + {{ title }} + </div> + </tooltip-on-truncate> + </div> + `, +}); +TruncateTarget.args = { + width: defaultWidth, + truncateTarget: 'child', + title: 'Wrap in container and use `truncate-target="child"` prop.', +}; diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue new file mode 100644 index 00000000000..09414e679bb --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue @@ -0,0 +1,85 @@ +<script> +import { GlTooltipDirective, GlResizeObserverDirective } from '@gitlab/ui'; +import { isFunction, debounce } from 'lodash'; +import { hasHorizontalOverflow } from '~/lib/utils/dom_utils'; + +const UPDATE_TOOLTIP_DEBOUNCED_WAIT_MS = 300; + +export default { + directives: { + GlTooltip: GlTooltipDirective, + GlResizeObserver: GlResizeObserverDirective, + }, + props: { + title: { + type: String, + required: false, + default: '', + }, + placement: { + type: String, + required: false, + default: 'top', + }, + truncateTarget: { + type: [String, Function], + required: false, + default: '', + }, + }, + data() { + return { + tooltipDisabled: true, + }; + }, + computed: { + classes() { + if (this.tooltipDisabled) { + return ''; + } + return 'js-show-tooltip'; + }, + tooltip() { + return { + title: this.title, + placement: this.placement, + disabled: this.tooltipDisabled, + }; + }, + }, + watch: { + title() { + // Wait on $nextTick in case the slot width changes + this.$nextTick(this.updateTooltip); + }, + }, + created() { + this.updateTooltipDebounced = debounce(this.updateTooltip, UPDATE_TOOLTIP_DEBOUNCED_WAIT_MS); + }, + mounted() { + this.updateTooltip(); + }, + methods: { + selectTarget() { + if (isFunction(this.truncateTarget)) { + return this.truncateTarget(this.$el); + } else if (this.truncateTarget === 'child') { + return this.$el.childNodes[0]; + } + return this.$el; + }, + updateTooltip() { + this.tooltipDisabled = !hasHorizontalOverflow(this.selectTarget()); + }, + onResize() { + this.updateTooltipDebounced(); + }, + }, +}; +</script> + +<template> + <span v-gl-tooltip="tooltip" v-gl-resize-observer="onResize" :class="classes" class="gl-min-w-0"> + <slot></slot> + </span> +</template> diff --git a/app/assets/javascripts/issuable_create/components/issuable_create_root.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue index f4cbaba9313..f4cbaba9313 100644 --- a/app/assets/javascripts/issuable_create/components/issuable_create_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue diff --git a/app/assets/javascripts/issuable_create/components/issuable_form.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue index c216a05bdb0..c216a05bdb0 100644 --- a/app/assets/javascripts/issuable_create/components/issuable_form.vue +++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue diff --git a/app/assets/javascripts/issuable_list/components/issuable_bulk_edit_sidebar.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue index 5ca9e50d854..5ca9e50d854 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_bulk_edit_sidebar.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue index ab04c6a38a5..0bb0e0d9fb0 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_item.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue @@ -6,7 +6,7 @@ import { isScopedLabel } from '~/lib/utils/common_utils'; import { differenceInSeconds, getTimeago, SECONDS_IN_DAY } from '~/lib/utils/datetime_utility'; import { isExternal, setUrlFragment } from '~/lib/utils/url_utility'; import { __, n__, sprintf } from '~/locale'; -import IssuableAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; +import IssuableAssignees from '~/issuable/components/issue_assignees.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; export default { @@ -166,6 +166,7 @@ export default { class="issue gl-display-flex! gl-px-5!" :class="{ closed: issuable.closedAt, today: createdInPastDay }" :data-labels="labelIdsString" + :data-qa-issue-id="issuableId" > <gl-form-checkbox v-if="showCheckbox" @@ -185,6 +186,13 @@ export default { :title="__('Confidential')" :aria-label="__('Confidential')" /> + <gl-icon + v-if="issuable.hidden" + v-gl-tooltip + name="spam" + :title="__('This issue is hidden because its author has been banned')" + :aria-label="__('Hidden')" + /> <gl-link class="issue-title-text" dir="auto" :href="webUrl" v-bind="issuableTitleProps"> {{ issuable.title }} <gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" /> @@ -202,7 +210,7 @@ export default { <span v-else data-testid="issuable-reference" class="issuable-reference"> {{ reference }} </span> - <span class="gl-display-none gl-sm-display-inline-block"> + <span class="gl-display-none gl-sm-display-inline"> <span aria-hidden="true">·</span> <span class="issuable-authored gl-mr-3"> <gl-sprintf :message="__('created %{timeAgo} by %{author}')"> diff --git a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue index c1082987146..2f8401b45f0 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue @@ -1,5 +1,5 @@ <script> -import { GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui'; +import { GlAlert, GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; @@ -19,6 +19,7 @@ export default { tag: 'ul', }, components: { + GlAlert, GlKeysetPagination, GlSkeletonLoading, IssuableTabs, @@ -156,6 +157,11 @@ export default { required: false, default: false, }, + error: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -272,10 +278,12 @@ export default { :show-checkbox="showBulkEditSidebar" :checkbox-checked="allIssuablesChecked" class="gl-flex-grow-1 gl-border-t-none row-content-block" + data-qa-selector="issuable_search_container" @checked-input="handleAllIssuablesCheckedInput" @onFilter="$emit('filter', $event)" @onSort="$emit('sort', $event)" /> + <gl-alert v-if="error" variant="danger" @dismiss="$emit('dismiss-alert')">{{ error }}</gl-alert> <issuable-bulk-edit-sidebar :expanded="showBulkEditSidebar"> <template #bulk-edit-actions> <slot name="bulk-edit-actions" :checked-issuables="bulkEditIssuables"></slot> @@ -302,6 +310,8 @@ export default { v-for="issuable in issuables" :key="issuableId(issuable)" :class="{ 'gl-cursor-grab': isManualOrdering }" + data-qa-selector="issuable_container" + :data-qa-issuable-title="issuable.title" :issuable-symbol="issuableSymbol" :issuable="issuable" :enable-label-permalinks="enableLabelPermalinks" diff --git a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue index 96b07031a11..3ff87ba3c4f 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue @@ -46,7 +46,9 @@ export default { @click="$emit('click', tab.name)" > <template #title> - <span :title="tab.titleTooltip">{{ tab.title }}</span> + <span :title="tab.titleTooltip" :data-qa-selector="`${tab.name}_issuables_tab`"> + {{ tab.title }} + </span> <gl-badge v-if="tabCounts && isTabCountNumeric(tab)" variant="muted" diff --git a/app/assets/javascripts/issuable_list/constants.js b/app/assets/javascripts/vue_shared/issuable/list/constants.js index 773ad0f8e93..773ad0f8e93 100644 --- a/app/assets/javascripts/issuable_list/constants.js +++ b/app/assets/javascripts/vue_shared/issuable/list/constants.js diff --git a/app/assets/javascripts/issuable_show/components/issuable_body.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue index 05dc1650379..05dc1650379 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_body.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue diff --git a/app/assets/javascripts/issuable_show/components/issuable_description.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue index f57b5b2deb4..f57b5b2deb4 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_description.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue diff --git a/app/assets/javascripts/issuable_show/components/issuable_discussion.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_discussion.vue index 5858af6cc51..5858af6cc51 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_discussion.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_discussion.vue diff --git a/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue index 33dca3e9332..33dca3e9332 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_edit_form.vue diff --git a/app/assets/javascripts/issuable_show/components/issuable_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue index d7da533d055..d7da533d055 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_header.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue diff --git a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue index 011db52cbe3..8849af2a52e 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue @@ -1,5 +1,5 @@ <script> -import IssuableSidebar from '~/issuable_sidebar/components/issuable_sidebar_root.vue'; +import IssuableSidebar from '~/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue'; import IssuableBody from './issuable_body.vue'; import IssuableDiscussion from './issuable_discussion.vue'; @@ -100,7 +100,7 @@ export default { </script> <template> - <div class="issuable-show-container"> + <div class="issuable-show-container" data-qa-selector="issuable_show_container"> <issuable-header :status-badge-class="statusBadgeClass" :status-icon="statusIcon" diff --git a/app/assets/javascripts/issuable_show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue index b96ce0c43f7..b96ce0c43f7 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_title.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue diff --git a/app/assets/javascripts/issuable_show/constants.js b/app/assets/javascripts/vue_shared/issuable/show/constants.js index 346f45c7d90..346f45c7d90 100644 --- a/app/assets/javascripts/issuable_show/constants.js +++ b/app/assets/javascripts/vue_shared/issuable/show/constants.js diff --git a/app/assets/javascripts/pages/projects/labels/event_hub.js b/app/assets/javascripts/vue_shared/issuable/show/event_hub.js index e31806ad199..e31806ad199 100644 --- a/app/assets/javascripts/pages/projects/labels/event_hub.js +++ b/app/assets/javascripts/vue_shared/issuable/show/event_hub.js diff --git a/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue b/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue index 99dcccd12ed..99dcccd12ed 100644 --- a/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue diff --git a/app/assets/javascripts/issuable_sidebar/constants.js b/app/assets/javascripts/vue_shared/issuable/sidebar/constants.js index 4f4b6341a1c..4f4b6341a1c 100644 --- a/app/assets/javascripts/issuable_sidebar/constants.js +++ b/app/assets/javascripts/vue_shared/issuable/sidebar/constants.js diff --git a/app/assets/javascripts/vue_shared/mixins/issuable.js b/app/assets/javascripts/vue_shared/mixins/issuable.js deleted file mode 100644 index fab0919d96e..00000000000 --- a/app/assets/javascripts/vue_shared/mixins/issuable.js +++ /dev/null @@ -1,14 +0,0 @@ -export default { - props: { - issuableType: { - required: true, - type: String, - }, - }, - - computed: { - issuableDisplayName() { - return this.issuableType.replace(/_/g, ' '); - }, - }, -}; diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue index 42272c222fc..d1630c9ac13 100644 --- a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue +++ b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue @@ -85,7 +85,7 @@ export default { ); }, i18n: { - buttonLabel: s__('SecurityConfiguration|Configure via Merge Request'), + buttonLabel: s__('SecurityConfiguration|Configure with a merge request'), noSuccessPathError: s__( 'SecurityConfiguration|%{featureName} merge request creation mutation failed', ), diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql index ae77a2ce5e4..829b9d9f9d8 100644 --- a/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql +++ b/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql @@ -1,6 +1,8 @@ fragment JobArtifacts on Pipeline { + id jobs(securityReportTypes: $reportTypes) { nodes { + id name artifacts { nodes { diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql index 4ce13827da2..2e80db30e9a 100644 --- a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql +++ b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql @@ -4,11 +4,14 @@ query securityReportDownloadPaths( $reportTypes: [SecurityReportTypeEnum!] ) { project(fullPath: $projectPath) { + id mergeRequest(iid: $iid) { + id headPipeline { id jobs(securityReportTypes: $reportTypes) { nodes { + id name artifacts { nodes { diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql index e1f3c55a886..e4f0c392b91 100644 --- a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql +++ b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql @@ -2,8 +2,8 @@ query getPipelineCorpuses($projectPath: ID!, $iid: ID, $reportTypes: [SecurityReportTypeEnum!]) { project(fullPath: $projectPath) { + id pipeline(iid: $iid) { - id ...JobArtifacts } } diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue new file mode 100644 index 00000000000..5e9e50a94f0 --- /dev/null +++ b/app/assets/javascripts/work_items/components/item_title.vue @@ -0,0 +1,71 @@ +<script> +import { escape } from 'lodash'; +import { __ } from '~/locale'; + +export default { + props: { + initialTitle: { + type: String, + required: false, + default: '', + }, + placeholder: { + type: String, + required: false, + default: __('Add a title...'), + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + title: this.initialTitle, + }; + }, + methods: { + getSanitizedTitle(inputEl) { + const { innerText } = inputEl; + return escape(innerText); + }, + handleBlur({ target }) { + this.$emit('title-changed', this.getSanitizedTitle(target)); + }, + handleInput({ target }) { + this.$emit('title-input', this.getSanitizedTitle(target)); + }, + handleSubmit() { + this.$refs.titleEl.blur(); + }, + }, +}; +</script> + +<template> + <h2 + class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5 gl-display-inline-block" + :class="{ 'gl-cursor-not-allowed': disabled }" + data-testid="title" + aria-labelledby="item-title" + > + <span + id="item-title" + ref="titleEl" + role="textbox" + :aria-label="__('Title')" + :data-placeholder="placeholder" + :contenteditable="!disabled" + class="gl-pseudo-placeholder" + @blur="handleBlur" + @keyup="handleInput" + @keydown.enter.exact="handleSubmit" + @keydown.ctrl.u.prevent + @keydown.meta.u.prevent + @keydown.ctrl.b.prevent + @keydown.meta.b.prevent + >{{ title }}</span + > + </h2> +</template> diff --git a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql new file mode 100644 index 00000000000..2f302dae7d7 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql @@ -0,0 +1,18 @@ +#import './widget.fragment.graphql' + +mutation createWorkItem($input: LocalCreateWorkItemInput) { + localCreateWorkItem(input: $input) @client { + workItem { + id + type + widgets { + nodes { + ...WidgetBase + ... on LocalTitleWidget { + contentText + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/fragmentTypes.json b/app/assets/javascripts/work_items/graphql/fragmentTypes.json index c048ac34ac0..3b837e84ee9 100644 --- a/app/assets/javascripts/work_items/graphql/fragmentTypes.json +++ b/app/assets/javascripts/work_items/graphql/fragmentTypes.json @@ -1 +1 @@ -{"__schema":{"types":[{"kind":"INTERFACE","name":"WorkItemWidget","possibleTypes":[{"name":"TitleWidget"}]}]}} +{"__schema":{"types":[{"kind":"INTERFACE","name":"LocalWorkItemWidget","possibleTypes":[{"name":"LocalTitleWidget"}]}]}} diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js index 083735336ce..fb536a425c0 100644 --- a/app/assets/javascripts/work_items/graphql/provider.js +++ b/app/assets/javascripts/work_items/graphql/provider.js @@ -4,6 +4,7 @@ import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import createDefaultClient from '~/lib/graphql'; import workItemQuery from './work_item.query.graphql'; import introspectionQueryResultData from './fragmentTypes.json'; +import { resolvers } from './resolvers'; import typeDefs from './typedefs.graphql'; const fragmentMatcher = new IntrospectionFragmentMatcher({ @@ -13,15 +14,12 @@ const fragmentMatcher = new IntrospectionFragmentMatcher({ export function createApolloProvider() { Vue.use(VueApollo); - const defaultClient = createDefaultClient( - {}, - { - cacheConfig: { - fragmentMatcher, - }, - typeDefs, + const defaultClient = createDefaultClient(resolvers, { + cacheConfig: { + fragmentMatcher, }, - ); + typeDefs, + }); defaultClient.cache.writeQuery({ query: workItemQuery, @@ -30,14 +28,14 @@ export function createApolloProvider() { }, data: { workItem: { - __typename: 'WorkItem', + __typename: 'LocalWorkItem', id: '1', type: 'FEATURE', widgets: { - __typename: 'WorkItemWidgetConnection', + __typename: 'LocalWorkItemWidgetConnection', nodes: [ { - __typename: 'TitleWidget', + __typename: 'LocalTitleWidget', type: 'TITLE', enabled: true, // eslint-disable-next-line @gitlab/require-i18n-strings diff --git a/app/assets/javascripts/work_items/graphql/resolvers.js b/app/assets/javascripts/work_items/graphql/resolvers.js index e69de29bb2d..63d5234d083 100644 --- a/app/assets/javascripts/work_items/graphql/resolvers.js +++ b/app/assets/javascripts/work_items/graphql/resolvers.js @@ -0,0 +1,58 @@ +import { uuids } from '~/lib/utils/uuids'; +import workItemQuery from './work_item.query.graphql'; + +export const resolvers = { + Mutation: { + localCreateWorkItem(_, { input }, { cache }) { + const id = uuids()[0]; + const workItem = { + __typename: 'LocalWorkItem', + type: 'FEATURE', + id, + widgets: { + __typename: 'LocalWorkItemWidgetConnection', + nodes: [ + { + __typename: 'LocalTitleWidget', + type: 'TITLE', + enabled: true, + contentText: input.title, + }, + ], + }, + }; + + cache.writeQuery({ query: workItemQuery, variables: { id }, data: { workItem } }); + + return { + __typename: 'LocalCreateWorkItemPayload', + workItem, + }; + }, + + localUpdateWorkItem(_, { input }, { cache }) { + const workItemTitle = { + __typename: 'LocalTitleWidget', + type: 'TITLE', + enabled: true, + contentText: input.title, + }; + const workItem = { + __typename: 'LocalWorkItem', + type: 'FEATURE', + id: input.id, + widgets: { + __typename: 'LocalWorkItemWidgetConnection', + nodes: [workItemTitle], + }, + }; + + cache.writeQuery({ query: workItemQuery, variables: { id: input.id }, data: { workItem } }); + + return { + __typename: 'LocalUpdateWorkItemPayload', + workItem, + }; + }, + }, +}; diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql index 4a6e4aeed60..177eea00322 100644 --- a/app/assets/javascripts/work_items/graphql/typedefs.graphql +++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql @@ -1,38 +1,60 @@ -enum WorkItemType { +enum LocalWorkItemType { FEATURE } -enum WidgetType { +enum LocalWidgetType { TITLE } -interface WorkItemWidget { - type: WidgetType! +interface LocalWorkItemWidget { + type: LocalWidgetType! } # Replicating Relay connection type for client schema -type WorkItemWidgetEdge { +type LocalWorkItemWidgetEdge { cursor: String! - node: WorkItemWidget + node: LocalWorkItemWidget } -type WorkItemWidgetConnection { - edges: [WorkItemWidgetEdge] - nodes: [WorkItemWidget] +type LocalWorkItemWidgetConnection { + edges: [LocalWorkItemWidgetEdge] + nodes: [LocalWorkItemWidget] pageInfo: PageInfo! } -type TitleWidget implements WorkItemWidget { - type: WidgetType! +type LocalTitleWidget implements LocalWorkItemWidget { + type: LocalWidgetType! contentText: String! } -type WorkItem { +type LocalWorkItem { id: ID! - type: WorkItemType! - widgets: [WorkItemWidgetConnection] + type: LocalWorkItemType! + widgets: [LocalWorkItemWidgetConnection] +} + +input LocalCreateWorkItemInput { + title: String! +} + +input LocalUpdateWorkItemInput { + id: ID! + title: String +} + +type LocalCreateWorkItemPayload { + workItem: LocalWorkItem! +} + +type LocalUpdateWorkItemPayload { + workItem: LocalWorkItem! } extend type Query { - workItem(id: ID!): WorkItem! + workItem(id: ID!): LocalWorkItem! +} + +extend type Mutation { + localCreateWorkItem(input: LocalCreateWorkItemInput!): LocalCreateWorkItemPayload! + localUpdateWorkItem(input: LocalUpdateWorkItemInput!): LocalUpdateWorkItemPayload! } diff --git a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql new file mode 100644 index 00000000000..f0563f099b2 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql @@ -0,0 +1,18 @@ +#import './widget.fragment.graphql' + +mutation updateWorkItem($input: LocalUpdateWorkItemInput) { + localUpdateWorkItem(input: $input) @client { + workItem { + id + type + widgets { + nodes { + ...WidgetBase + ... on LocalTitleWidget { + contentText + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/widget.fragment.graphql b/app/assets/javascripts/work_items/graphql/widget.fragment.graphql index d7608c26052..154367dc0d8 100644 --- a/app/assets/javascripts/work_items/graphql/widget.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/widget.fragment.graphql @@ -1,3 +1,3 @@ -fragment WidgetBase on WorkItemWidget { +fragment WidgetBase on LocalWorkItemWidget { type } diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql index 549e4f8c65a..9f173f7c302 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql @@ -7,7 +7,7 @@ query WorkItem($id: ID!) { widgets { nodes { ...WidgetBase - ... on TitleWidget { + ... on LocalTitleWidget { contentText } } diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue new file mode 100644 index 00000000000..12bad5606d4 --- /dev/null +++ b/app/assets/javascripts/work_items/pages/create_work_item.vue @@ -0,0 +1,71 @@ +<script> +import { GlButton, GlAlert } from '@gitlab/ui'; +import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql'; + +import ItemTitle from '../components/item_title.vue'; + +export default { + components: { + GlButton, + GlAlert, + ItemTitle, + }, + data() { + return { + title: '', + error: false, + }; + }, + methods: { + async createWorkItem() { + try { + const response = await this.$apollo.mutate({ + mutation: createWorkItemMutation, + variables: { + input: { + title: this.title, + }, + }, + }); + + const { + data: { + localCreateWorkItem: { + workItem: { id }, + }, + }, + } = response; + this.$router.push({ name: 'workItem', params: { id } }); + } catch { + this.error = true; + } + }, + handleTitleInput(title) { + this.title = title; + }, + }, +}; +</script> + +<template> + <form @submit.prevent="createWorkItem"> + <gl-alert v-if="error" variant="danger" @dismiss="error = false">{{ + __('Something went wrong when creating a work item. Please try again') + }}</gl-alert> + <item-title data-testid="title-input" @title-input="handleTitleInput" /> + <div class="gl-bg-gray-10 gl-py-5 gl-px-6"> + <gl-button + variant="confirm" + :disabled="title.length === 0" + class="gl-mr-3" + data-testid="create-button" + type="submit" + > + {{ __('Create') }} + </gl-button> + <gl-button type="button" data-testid="cancel-button" @click="$router.go(-1)"> + {{ __('Cancel') }} + </gl-button> + </div> + </form> +</template> diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue index 493ee0aba01..479274baf3a 100644 --- a/app/assets/javascripts/work_items/pages/work_item_root.vue +++ b/app/assets/javascripts/work_items/pages/work_item_root.vue @@ -1,8 +1,16 @@ <script> +import { GlAlert } from '@gitlab/ui'; import workItemQuery from '../graphql/work_item.query.graphql'; +import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import { widgetTypes } from '../constants'; +import ItemTitle from '../components/item_title.vue'; + export default { + components: { + ItemTitle, + GlAlert, + }, props: { id: { type: String, @@ -12,6 +20,7 @@ export default { data() { return { workItem: null, + error: false, }; }, apollo: { @@ -29,20 +38,39 @@ export default { return this.workItem?.widgets?.nodes?.find((widget) => widget.type === widgetTypes.title); }, }, + methods: { + async updateWorkItem(title) { + try { + await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.id, + title, + }, + }, + }); + } catch { + this.error = true; + } + }, + }, }; </script> <template> <section> + <gl-alert v-if="error" variant="danger" @dismiss="error = false">{{ + __('Something went wrong while updating work item. Please try again') + }}</gl-alert> <!-- Title widget placeholder --> <div> - <h2 + <item-title v-if="titleWidgetData" - class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5" + :initial-title="titleWidgetData.contentText" data-testid="title" - > - {{ titleWidgetData.contentText }} - </h2> + @title-changed="updateWorkItem" + /> </div> </section> </template> diff --git a/app/assets/javascripts/work_items/router/routes.js b/app/assets/javascripts/work_items/router/routes.js index a3cf44ad4ca..95772bbd026 100644 --- a/app/assets/javascripts/work_items/router/routes.js +++ b/app/assets/javascripts/work_items/router/routes.js @@ -1,7 +1,12 @@ export const routes = [ { + path: '/new', + name: 'createWorkItem', + component: () => import('../pages/create_work_item.vue'), + }, + { path: '/:id', - name: 'work_item', + name: 'workItem', component: () => import('../pages/work_item_root.vue'), props: true, }, |