diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-09-30 15:09:53 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-09-30 15:09:53 +0300 |
commit | 6aa5c04c74d2d70ee7d19ef3a155b2def9dd46de (patch) | |
tree | 81f7b81234bc5b889c57e71f87b94878ab286383 /app | |
parent | 418c3b29009dcc0a2c6b4872557d0274ba0b8077 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
35 files changed, 620 insertions, 339 deletions
diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js index 68fca706ce5..252706b3647 100644 --- a/app/assets/javascripts/members.js +++ b/app/assets/javascripts/members.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import { Rails } from '~/lib/utils/rails_ujs'; import { disableButtonIfEmptyField } from '~/lib/utils/common_utils'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import { __, sprintf } from '~/locale'; export default class Members { constructor() { @@ -64,7 +65,28 @@ export default class Members { } formSuccess(e) { - const { $toggle, $dateInput } = this.getMemberListItems($(e.currentTarget).closest('.member')); + const { $toggle, $dateInput, $expiresIn, $expiresInText } = this.getMemberListItems( + $(e.currentTarget).closest('.js-member'), + ); + + const [data] = e.detail; + const expiresIn = data?.expires_in; + + if (expiresIn) { + $expiresIn.removeClass('gl-display-none'); + + $expiresInText.text(sprintf(__('Expires in %{expires_at}'), { expires_at: expiresIn })); + + const { expires_soon: expiresSoon } = data; + + if (expiresSoon) { + $expiresInText.addClass('text-warning'); + } else { + $expiresInText.removeClass('text-warning'); + } + } else { + $expiresIn.addClass('gl-display-none'); + } $toggle.enable(); $dateInput.enable(); @@ -72,10 +94,12 @@ export default class Members { // eslint-disable-next-line class-methods-use-this getMemberListItems($el) { - const $memberListItem = $el.is('.member') ? $el : $(`#${$el.data('elId')}`); + const $memberListItem = $el.is('.js-member') ? $el : $(`#${$el.data('elId')}`); return { $memberListItem, + $expiresIn: $memberListItem.find('.js-expires-in'), + $expiresInText: $memberListItem.find('.js-expires-in-text'), $toggle: $memberListItem.find('.dropdown-menu-toggle'), $dateInput: $memberListItem.find('.js-access-expiration-date'), }; diff --git a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue index 2ee7bbef4c6..fcb86fd18f0 100644 --- a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue +++ b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue @@ -1,7 +1,7 @@ <script> -import { mapActions, mapGetters, mapState } from 'vuex'; import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; - +import { isEqual } from 'lodash'; +import expirationPolicyQuery from '../graphql/queries/get_expiration_policy.graphql'; import { FETCH_SETTINGS_ERROR_MESSAGE } from '../../shared/constants'; import SettingsForm from './settings_form.vue'; @@ -19,21 +19,39 @@ export default { GlSprintf, GlLink, }, + inject: ['projectPath', 'isAdmin', 'adminSettingsPath', 'enableHistoricEntries'], i18n: { UNAVAILABLE_FEATURE_TITLE, UNAVAILABLE_FEATURE_INTRO_TEXT, FETCH_SETTINGS_ERROR_MESSAGE, }, + apollo: { + containerExpirationPolicy: { + query: expirationPolicyQuery, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update: data => data.project?.containerExpirationPolicy, + result({ data }) { + this.workingCopy = { ...data.project?.containerExpirationPolicy }; + }, + error(e) { + this.fetchSettingsError = e; + }, + }, + }, data() { return { fetchSettingsError: false, + containerExpirationPolicy: null, + workingCopy: {}, }; }, computed: { - ...mapState(['isAdmin', 'adminSettingsPath']), - ...mapGetters({ isDisabled: 'getIsDisabled' }), - showSettingForm() { - return !this.isDisabled && !this.fetchSettingsError; + isDisabled() { + return !(this.containerExpirationPolicy || this.enableHistoricEntries); }, showDisabledFormMessage() { return this.isDisabled && !this.fetchSettingsError; @@ -41,21 +59,27 @@ export default { unavailableFeatureMessage() { return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT; }, - }, - mounted() { - this.fetchSettings().catch(() => { - this.fetchSettingsError = true; - }); + isEdited() { + return !isEqual(this.containerExpirationPolicy, this.workingCopy); + }, }, methods: { - ...mapActions(['fetchSettings']), + restoreOriginal() { + this.workingCopy = { ...this.containerExpirationPolicy }; + }, }, }; </script> <template> <div> - <settings-form v-if="showSettingForm" /> + <settings-form + v-if="containerExpirationPolicy" + v-model="workingCopy" + :is-loading="$apollo.queries.containerExpirationPolicy.loading" + :is-edited="isEdited" + @reset="restoreOriginal" + /> <template v-else> <gl-alert v-if="showDisabledFormMessage" diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue index 25c88daa54d..7deb1f92686 100644 --- a/app/assets/javascripts/registry/settings/components/settings_form.vue +++ b/app/assets/javascripts/registry/settings/components/settings_form.vue @@ -1,28 +1,45 @@ <script> -import { get } from 'lodash'; -import { mapActions, mapState, mapGetters } from 'vuex'; -import { GlCard, GlButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlCard, GlButton } from '@gitlab/ui'; import Tracking from '~/tracking'; -import { mapComputed } from '~/vuex_shared/bindings'; import { UPDATE_SETTINGS_ERROR_MESSAGE, UPDATE_SETTINGS_SUCCESS_MESSAGE, } from '../../shared/constants'; import ExpirationPolicyFields from '../../shared/components/expiration_policy_fields.vue'; import { SET_CLEANUP_POLICY_BUTTON, CLEANUP_POLICY_CARD_HEADER } from '../constants'; +import { formOptionsGenerator } from '~/registry/shared/utils'; +import updateContainerExpirationPolicyMutation from '../graphql/mutations/update_container_expiration_policy.graphql'; +import { updateContainerExpirationPolicy } from '../graphql/utils/cache_update'; export default { components: { GlCard, GlButton, - GlLoadingIcon, ExpirationPolicyFields, }, mixins: [Tracking.mixin()], + inject: ['projectPath'], + props: { + value: { + type: Object, + required: true, + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + isEdited: { + type: Boolean, + required: false, + default: false, + }, + }, labelsConfig: { cols: 3, align: 'right', }, + formOptions: formOptionsGenerator(), i18n: { CLEANUP_POLICY_CARD_HEADER, SET_CLEANUP_POLICY_BUTTON, @@ -34,49 +51,74 @@ export default { }, fieldsAreValid: true, apiErrors: null, + mutationLoading: false, }; }, computed: { - ...mapState(['formOptions', 'isLoading']), - ...mapGetters({ isEdited: 'getIsEdited' }), - ...mapComputed([{ key: 'settings', getter: 'getSettings' }], 'updateSettings'), + showLoadingIcon() { + return this.isLoading || this.mutationLoading; + }, isSubmitButtonDisabled() { - return !this.fieldsAreValid || this.isLoading; + return !this.fieldsAreValid || this.showLoadingIcon; }, isCancelButtonDisabled() { - return !this.isEdited || this.isLoading; + return !this.isEdited || this.isLoading || this.mutationLoading; + }, + mutationVariables() { + return { + projectPath: this.projectPath, + enabled: this.value.enabled, + cadence: this.value.cadence, + olderThan: this.value.olderThan, + keepN: this.value.keepN, + nameRegex: this.value.nameRegex, + nameRegexKeep: this.value.nameRegexKeep, + }; }, }, methods: { - ...mapActions(['resetSettings', 'saveSettings']), reset() { this.track('reset_form'); this.apiErrors = null; - this.resetSettings(); + this.$emit('reset'); }, setApiErrors(response) { - const messages = get(response, 'data.message', []); - - this.apiErrors = Object.keys(messages).reduce((acc, curr) => { - if (curr.startsWith('container_expiration_policy.')) { - const key = curr.replace('container_expiration_policy.', ''); - acc[key] = get(messages, [curr, 0], ''); - } + this.apiErrors = response.graphQLErrors.reduce((acc, curr) => { + curr.extensions.problems.forEach(item => { + acc[item.path[0]] = item.message; + }); return acc; }, {}); }, submit() { this.track('submit_form'); this.apiErrors = null; - this.saveSettings() - .then(() => this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' })) - .catch(({ response }) => { - this.setApiErrors(response); + this.mutationLoading = true; + return this.$apollo + .mutate({ + mutation: updateContainerExpirationPolicyMutation, + variables: { + input: this.mutationVariables, + }, + update: updateContainerExpirationPolicy(this.projectPath), + }) + .then(({ data }) => { + const errorMessage = data?.updateContainerExpirationPolicy?.errors[0]; + if (errorMessage) { + this.$toast.show(errorMessage, { type: 'error' }); + } + this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' }); + }) + .catch(error => { + this.setApiErrors(error); this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error' }); + }) + .finally(() => { + this.mutationLoading = false; }); }, onModelChange(changePayload) { - this.settings = changePayload.newValue; + this.$emit('input', changePayload.newValue); if (this.apiErrors) { this.apiErrors[changePayload.modified] = undefined; } @@ -93,8 +135,8 @@ export default { </template> <template #default> <expiration-policy-fields - :value="settings" - :form-options="formOptions" + :value="value" + :form-options="$options.formOptions" :is-loading="isLoading" :api-errors="apiErrors" @validated="fieldsAreValid = true" @@ -115,12 +157,12 @@ export default { ref="save-button" type="submit" :disabled="isSubmitButtonDisabled" + :loading="showLoadingIcon" variant="success" category="primary" class="js-no-auto-disable" > {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }} - <gl-loading-icon v-if="isLoading" class="gl-ml-3" /> </gl-button> </template> </gl-card> diff --git a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue index 1ff2f6f99e5..2b8e9f6ff64 100644 --- a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue +++ b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue @@ -68,34 +68,31 @@ export default { { name: 'expiration-policy-interval', label: EXPIRATION_INTERVAL_LABEL, - model: 'older_than', - optionKey: 'olderThan', + model: 'olderThan', }, { name: 'expiration-policy-schedule', label: EXPIRATION_SCHEDULE_LABEL, model: 'cadence', - optionKey: 'cadence', }, { name: 'expiration-policy-latest', label: KEEP_N_LABEL, - model: 'keep_n', - optionKey: 'keepN', + model: 'keepN', }, ], textAreaList: [ { name: 'expiration-policy-name-matching', label: NAME_REGEX_LABEL, - model: 'name_regex', + model: 'nameRegex', placeholder: NAME_REGEX_PLACEHOLDER, description: NAME_REGEX_DESCRIPTION, }, { name: 'expiration-policy-keep-name', label: NAME_REGEX_KEEP_LABEL, - model: 'name_regex_keep', + model: 'nameRegexKeep', placeholder: NAME_REGEX_KEEP_PLACEHOLDER, description: NAME_REGEX_KEEP_DESCRIPTION, }, @@ -107,17 +104,16 @@ export default { }, computed: { ...mapComputedToEvent( - ['enabled', 'cadence', 'older_than', 'keep_n', 'name_regex', 'name_regex_keep'], + ['enabled', 'cadence', 'olderThan', 'keepN', 'nameRegex', 'nameRegexKeep'], 'value', ), policyEnabledText() { return this.enabled ? ENABLED_TEXT : DISABLED_TEXT; }, textAreaValidation() { - const nameRegexErrors = - this.apiErrors?.name_regex || this.validateRegexLength(this.name_regex); + const nameRegexErrors = this.apiErrors?.nameRegex || this.validateRegexLength(this.nameRegex); const nameKeepRegexErrors = - this.apiErrors?.name_regex_keep || this.validateRegexLength(this.name_regex_keep); + this.apiErrors?.nameRegexKeep || this.validateRegexLength(this.nameRegexKeep); return { /* @@ -127,11 +123,11 @@ export default { * false: red border, error message * So in this function we keep null if the are no message otherwise we 'invert' the error message */ - name_regex: { + nameRegex: { state: nameRegexErrors === null ? null : !nameRegexErrors, message: nameRegexErrors, }, - name_regex_keep: { + nameRegexKeep: { state: nameKeepRegexErrors === null ? null : !nameKeepRegexErrors, message: nameKeepRegexErrors, }, @@ -139,8 +135,8 @@ export default { }, fieldsValidity() { return ( - this.textAreaValidation.name_regex.state !== false && - this.textAreaValidation.name_regex_keep.state !== false + this.textAreaValidation.nameRegex.state !== false && + this.textAreaValidation.nameRegexKeep.state !== false ); }, isFormElementDisabled() { @@ -216,11 +212,7 @@ export default { :disabled="isFormElementDisabled" @input="updateModel($event, select.model)" > - <option - v-for="option in formOptions[select.optionKey]" - :key="option.key" - :value="option.key" - > + <option v-for="option in formOptions[select.model]" :key="option.key" :value="option.key"> {{ option.label }} </option> </gl-form-select> diff --git a/app/assets/javascripts/registry/shared/utils.js b/app/assets/javascripts/registry/shared/utils.js index f84325cd438..bdf1ab9507d 100644 --- a/app/assets/javascripts/registry/shared/utils.js +++ b/app/assets/javascripts/registry/shared/utils.js @@ -21,20 +21,26 @@ export const mapComputedToEvent = (list, root) => { return result; }; -export const optionLabelGenerator = (collection, singularSentence, pluralSentence) => +export const olderThanTranslationGenerator = variable => + n__( + '%d day until tags are automatically removed', + '%d days until tags are automatically removed', + variable, + ); + +export const keepNTranslationGenerator = variable => + n__('%d tag per image name', '%d tags per image name', variable); + +export const optionLabelGenerator = (collection, translationFn) => collection.map(option => ({ ...option, - label: n__(singularSentence, pluralSentence, option.variable), + label: translationFn(option.variable), })); export const formOptionsGenerator = () => { return { - olderThan: optionLabelGenerator( - OLDER_THAN_OPTIONS, - '%d days until tags are automatically removed', - '%d day until tags are automatically removed', - ), + olderThan: optionLabelGenerator(OLDER_THAN_OPTIONS, olderThanTranslationGenerator), cadence: CADENCE_OPTIONS, - keepN: optionLabelGenerator(KEEP_N_OPTIONS, '%d tag per image name', '%d tags per image name'), + keepN: optionLabelGenerator(KEEP_N_OPTIONS, keepNTranslationGenerator), }; }; diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index f3442e437b4..cae886bf846 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -1,11 +1,3 @@ -/* - * This is a manifest file that'll automatically include all the stylesheets available in this directory - * and any sub-directories. You're free to add application-wide styles to this file and they'll appear at - * the top of the compiled file, but it's generally better to create a new file per style scope. - *= require_self - *= require cropper.css -*/ - // Welcome to GitLab css! // If you need to add or modify UI component that is common for many pages // like a table or typography then make changes in the framework/ directory. @@ -14,6 +6,7 @@ @import '@gitlab/at.js/dist/css/jquery.atwho'; @import 'dropzone/dist/basic'; @import 'select2'; +@import 'cropper'; // GitLab UI framework @import 'framework'; diff --git a/app/assets/stylesheets/pages/experimental_separate_sign_up.scss b/app/assets/stylesheets/pages/experimental_separate_sign_up.scss index dfc56654229..415ff01bc33 100644 --- a/app/assets/stylesheets/pages/experimental_separate_sign_up.scss +++ b/app/assets/stylesheets/pages/experimental_separate_sign_up.scss @@ -57,4 +57,8 @@ height: $default-icon-size; } } + + .decline-page { + width: 350px; + } } diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index a88ebb34804..9ae54aa9cdf 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -386,271 +386,272 @@ } } } -} - -.pipeline-tab-content { - display: flex; - width: 100%; - min-height: $dropdown-max-height-lg; - background-color: $gray-light; - padding: $gl-padding 0; - overflow: auto; -} - -// Pipeline graph -.pipeline-graph { - white-space: nowrap; - transition: max-height 0.3s, padding 0.3s; - - .stage-column-list, - .builds-container > ul { - padding: 0; - } - a { - text-decoration: none; - color: $gl-text-color; - } - - svg { - vertical-align: middle; + .pipeline-tab-content { + display: flex; + width: 100%; + min-height: $dropdown-max-height-lg; + background-color: $gray-light; + padding: $gl-padding 0; + overflow: auto; } - .stage-column { - display: inline-block; - vertical-align: top; - - &.left-margin { - &:not(:first-child) { - margin-left: 44px; + // Pipeline graph, used at + // app/assets/javascripts/pipelines/components/graph/graph_component.vue + .pipeline-graph { + white-space: nowrap; + transition: max-height 0.3s, padding 0.3s; - .left-connector { - @include flat-connector-before; - } - } + .stage-column-list, + .builds-container > ul { + padding: 0; } - &.no-margin { - margin: 0; + a { + text-decoration: none; + color: $gl-text-color; } - li { - list-style: none; + svg { + vertical-align: middle; } - // when downstream pipelines are present, the last stage isn't the last column - &:last-child:not(.has-downstream) { - .build { - // Remove right connecting horizontal line from first build in last stage - &:first-child::after { - border: 0; - } - // Remove right curved connectors from all builds in last stage - &:not(:first-child)::after { - border: 0; - } - // Remove opposite curve - .curve::before { - display: none; - } - } - } + .stage-column { + display: inline-block; + vertical-align: top; - // when upstream pipelines are present, the first stage isn't the first column - &:first-child:not(.has-upstream) { - .build { - // Remove left curved connectors from all builds in first stage - &:not(:first-child)::before { - border: 0; - } - // Remove opposite curve - .curve::after { - display: none; + &.left-margin { + &:not(:first-child) { + margin-left: 44px; + + .left-connector { + @include flat-connector-before; + } } } - } - - // Curve first child connecting lines in opposite direction - .curve { - display: none; - &::before, - &::after { - content: ''; - width: 21px; - height: 25px; - position: absolute; - top: -31px; - border-top: 2px solid $border-color; + &.no-margin { + margin: 0; } - &::after { - left: -44px; - border-right: 2px solid $border-color; - border-radius: 0 20px; + li { + list-style: none; } - &::before { - right: -44px; - border-left: 2px solid $border-color; - border-radius: 20px 0 0; + // when downstream pipelines are present, the last stage isn't the last column + &:last-child:not(.has-downstream) { + .build { + // Remove right connecting horizontal line from first build in last stage + &:first-child::after { + border: 0; + } + // Remove right curved connectors from all builds in last stage + &:not(:first-child)::after { + border: 0; + } + // Remove opposite curve + .curve::before { + display: none; + } + } } - } - } - - .stage-name { - margin: 0 0 15px 10px; - font-weight: $gl-font-weight-bold; - width: 176px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: 2.2em; - } - .build { - position: relative; - width: 186px; - margin-bottom: 10px; - white-space: normal; - - .ci-job-dropdown-container { - // override dropdown.scss - .dropdown-menu li button { - padding: 0; - text-align: center; + // when upstream pipelines are present, the first stage isn't the first column + &:first-child:not(.has-upstream) { + .build { + // Remove left curved connectors from all builds in first stage + &:not(:first-child)::before { + border: 0; + } + // Remove opposite curve + .curve::after { + display: none; + } + } } - } - // ensure .build-content has hover style when action-icon is hovered - .ci-job-dropdown-container:hover .build-content { - @extend .build-content:hover; - } + // Curve first child connecting lines in opposite direction + .curve { + display: none; - .ci-status-icon svg { - height: 24px; - width: 24px; - } + &::before, + &::after { + content: ''; + width: 21px; + height: 25px; + position: absolute; + top: -31px; + border-top: 2px solid $border-color; + } - .dropdown-menu-toggle { - background-color: transparent; - border: 0; - padding: 0; + &::after { + left: -44px; + border-right: 2px solid $border-color; + border-radius: 0 20px; + } - &:focus { - outline: none; + &::before { + right: -44px; + border-left: 2px solid $border-color; + border-radius: 20px 0 0; + } } } - .build-content { - @include build-content(); + .stage-name { + margin: 0 0 15px 10px; + font-weight: $gl-font-weight-bold; + width: 176px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 2.2em; } - a.build-content:hover, - button.build-content:hover { - background-color: $gray-darker; - border: 1px solid $dropdown-toggle-active-border-color; - } + .build { + position: relative; + width: 186px; + margin-bottom: 10px; + white-space: normal; - // Connect first build in each stage with right horizontal line - &:first-child { - &::after { - content: ''; - position: absolute; - top: 48%; - right: -48px; - border-top: 2px solid $border-color; - width: 48px; - height: 1px; + .ci-job-dropdown-container { + // override dropdown.scss + .dropdown-menu li button { + padding: 0; + text-align: center; + } } - } - // Connect each build (except for first) with curved lines - &:not(:first-child) { - &::after, - &::before { - content: ''; - top: -49px; - position: absolute; - border-bottom: 2px solid $border-color; - width: 25px; - height: 69px; + // ensure .build-content has hover style when action-icon is hovered + .ci-job-dropdown-container:hover .build-content { + @extend .build-content:hover; } - // Right connecting curves - &::after { - right: -25px; - border-right: 2px solid $border-color; - border-radius: 0 0 20px; + .ci-status-icon svg { + height: 24px; + width: 24px; } - // Left connecting curves - &::before { - left: -25px; - border-left: 2px solid $border-color; - border-radius: 0 0 0 20px; + .dropdown-menu-toggle { + background-color: transparent; + border: 0; + padding: 0; + + &:focus { + outline: none; + } } - } - // Connect second build to first build with smaller curved line - &:nth-child(2) { - &::after, - &::before { - height: 29px; - top: -9px; + .build-content { + @include build-content(); } - .curve { - display: block; + a.build-content:hover, + button.build-content:hover { + background-color: $gray-darker; + border: 1px solid $dropdown-toggle-active-border-color; } - } - } - .ci-action-icon-container { - position: absolute; - right: 5px; - top: 50%; - transform: translateY(-50%); + // Connect first build in each stage with right horizontal line + &:first-child { + &::after { + content: ''; + position: absolute; + top: 48%; + right: -48px; + border-top: 2px solid $border-color; + width: 48px; + height: 1px; + } + } - // Action Icons in big pipeline-graph nodes - &.ci-action-icon-wrapper { - height: 30px; - width: 30px; - border-radius: 100%; - display: block; - padding: 0; - line-height: 0; + // Connect each build (except for first) with curved lines + &:not(:first-child) { + &::after, + &::before { + content: ''; + top: -49px; + position: absolute; + border-bottom: 2px solid $border-color; + width: 25px; + height: 69px; + } - svg { - fill: $gl-text-color-secondary; + // Right connecting curves + &::after { + right: -25px; + border-right: 2px solid $border-color; + border-radius: 0 0 20px; + } + + // Left connecting curves + &::before { + left: -25px; + border-left: 2px solid $border-color; + border-radius: 0 0 0 20px; + } } - .spinner { - top: 2px; + // Connect second build to first build with smaller curved line + &:nth-child(2) { + &::after, + &::before { + height: 29px; + top: -9px; + } + + .curve { + display: block; + } } + } + + .ci-action-icon-container { + position: absolute; + right: 5px; + top: 50%; + transform: translateY(-50%); + + // Action Icons in big pipeline-graph nodes + &.ci-action-icon-wrapper { + height: 30px; + width: 30px; + border-radius: 100%; + display: block; + padding: 0; + line-height: 0; - &.play { svg { - left: 1px; - top: 1px; + fill: $gl-text-color-secondary; + } + + .spinner { + top: 2px; + } + + &.play { + svg { + left: 1px; + top: 1px; + } } } } - } - .stage-action svg { - left: 1px; - top: -2px; + .stage-action svg { + left: 1px; + top: -2px; + } } -} -// Triggers the dropdown in the big pipeline graph -.dropdown-counter-badge { - font-weight: 100; - font-size: 15px; - position: absolute; - right: 13px; - top: 8px; + // Triggers the dropdown in the big pipeline graph + .dropdown-counter-badge { + font-weight: 100; + font-size: 15px; + position: absolute; + right: 13px; + top: 8px; + } } .ci-build-text, @@ -777,6 +778,7 @@ button.mini-pipeline-graph-dropdown-toggle { .ci-action-icon-container { position: absolute; right: 8px; + top: 8px; &.ci-action-icon-wrapper { height: $ci-action-dropdown-button-size; @@ -798,6 +800,8 @@ button.mini-pipeline-graph-dropdown-toggle { width: $ci-action-dropdown-svg-size; height: $ci-action-dropdown-svg-size; fill: $gl-text-color-secondary; + position: relative; + top: 1px; vertical-align: initial; } } diff --git a/app/controllers/groups/group_links_controller.rb b/app/controllers/groups/group_links_controller.rb index c395b93f4e7..06c793b5c4c 100644 --- a/app/controllers/groups/group_links_controller.rb +++ b/app/controllers/groups/group_links_controller.rb @@ -24,6 +24,15 @@ class Groups::GroupLinksController < Groups::ApplicationController def update Groups::GroupLinks::UpdateService.new(@group_link).execute(group_link_params) + + if @group_link.expires? + render json: { + expires_in: helpers.distance_of_time_in_words_to_now(@group_link.expires_at), + expires_soon: @group_link.expires_soon? + } + else + render json: {} + end end def destroy diff --git a/app/controllers/groups/registry/repositories_controller.rb b/app/controllers/groups/registry/repositories_controller.rb index 14651e0794a..87a62a8f9b0 100644 --- a/app/controllers/groups/registry/repositories_controller.rb +++ b/app/controllers/groups/registry/repositories_controller.rb @@ -2,6 +2,8 @@ module Groups module Registry class RepositoriesController < Groups::ApplicationController + include PackagesHelper + before_action :verify_container_registry_enabled! before_action :authorize_read_container_image! @@ -13,7 +15,7 @@ module Groups .execute .with_api_entity_associations - track_event(:list_repositories) + track_package_event(:list_repositories, :container) serializer = ContainerRepositoriesSerializer .new(current_user: current_user) diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index af5de684f32..591ded7630c 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -30,6 +30,8 @@ class InvitesController < ApplicationController def decline if member.decline_invite! + return render layout: 'devise_experimental_onboarding_issues' if !current_user && member.invite_to_unknown_user? && member.created_by + path = if current_user dashboard_projects_path diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb index a30c455a7e4..f8ea6f834a3 100644 --- a/app/controllers/projects/group_links_controller.rb +++ b/app/controllers/projects/group_links_controller.rb @@ -21,8 +21,17 @@ class Projects::GroupLinksController < Projects::ApplicationController end def update - @group_link = @project.project_group_links.find(params[:id]) - Projects::GroupLinks::UpdateService.new(@group_link).execute(group_link_params) + group_link = @project.project_group_links.find(params[:id]) + Projects::GroupLinks::UpdateService.new(group_link).execute(group_link_params) + + if group_link.expires? + render json: { + expires_in: helpers.distance_of_time_in_words_to_now(group_link.expires_at), + expires_soon: group_link.expires_soon? + } + else + render json: {} + end end def destroy diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 8aacfdce094..28aef6f4328 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -64,7 +64,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic render: ->(partial, locals) { view_to_html_string(partial, locals) } } - options = additional_attributes.merge(diff_view: Feature.enabled?(:unified_diff_lines, @merge_request.project) ? "inline" : diff_view) + options = additional_attributes.merge(diff_view: Feature.enabled?(:unified_diff_lines, @merge_request.project, default_enabled: true) ? "inline" : diff_view) if @merge_request.project.context_commits_enabled? options[:context_commits] = @merge_request.recent_context_commits diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index c5fa92ebf32..48272a7c22b 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -37,7 +37,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:approvals_commented_by, @project, default_enabled: true) push_frontend_feature_flag(:hide_jump_to_next_unresolved_in_threads, default_enabled: true) push_frontend_feature_flag(:merge_request_widget_graphql, @project) - push_frontend_feature_flag(:unified_diff_lines, @project) + push_frontend_feature_flag(:unified_diff_lines, @project, default_enabled: true) push_frontend_feature_flag(:highlight_current_diff_row, @project) push_frontend_feature_flag(:default_merge_ref_for_diffs, @project) end diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb index 19d0cb9acdc..28a86ecc9f0 100644 --- a/app/controllers/projects/registry/repositories_controller.rb +++ b/app/controllers/projects/registry/repositories_controller.rb @@ -3,6 +3,8 @@ module Projects module Registry class RepositoriesController < ::Projects::Registry::ApplicationController + include PackagesHelper + before_action :authorize_update_container_image!, only: [:destroy] before_action :ensure_root_container_repository!, only: [:index] @@ -13,7 +15,7 @@ module Projects @images = ContainerRepositoriesFinder.new(user: current_user, subject: project, params: params.slice(:name)) .execute - track_event(:list_repositories) + track_package_event(:list_repositories, :container) serializer = ContainerRepositoriesSerializer .new(project: project, current_user: current_user) @@ -31,7 +33,7 @@ module Projects def destroy image.delete_scheduled! DeleteContainerRepositoryWorker.perform_async(current_user.id, image.id) # rubocop:disable CodeReuse/Worker - track_event(:delete_repository) + track_package_event(:delete_repository, :container) respond_to do |format| format.json { head :no_content } diff --git a/app/controllers/projects/registry/tags_controller.rb b/app/controllers/projects/registry/tags_controller.rb index c42e3f6bdba..ebdb668207f 100644 --- a/app/controllers/projects/registry/tags_controller.rb +++ b/app/controllers/projects/registry/tags_controller.rb @@ -3,12 +3,15 @@ module Projects module Registry class TagsController < ::Projects::Registry::ApplicationController + include PackagesHelper + before_action :authorize_destroy_container_image!, only: [:destroy] LIMIT = 15 def index - track_event(:list_tags) + track_package_event(:list_tags, :tag) + respond_to do |format| format.json do render json: ContainerTagsSerializer @@ -23,7 +26,7 @@ module Projects result = Projects::ContainerRepository::DeleteTagsService .new(image.project, current_user, tags: [params[:id]]) .execute(image) - track_event(:delete_tag) + track_package_event(:delete_tag, :tag) respond_to do |format| format.json { head(result[:status] == :success ? :ok : bad_request) } @@ -40,7 +43,7 @@ module Projects result = Projects::ContainerRepository::DeleteTagsService .new(image.project, current_user, tags: tag_names) .execute(image) - track_event(:delete_tag_bulk) + track_package_event(:delete_tag_bulk, :tag) respond_to do |format| format.json { head(result[:status] == :success ? :no_content : :bad_request) } diff --git a/app/graphql/resolvers/terraform/states_resolver.rb b/app/graphql/resolvers/terraform/states_resolver.rb new file mode 100644 index 00000000000..38b26a948b1 --- /dev/null +++ b/app/graphql/resolvers/terraform/states_resolver.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Resolvers + module Terraform + class StatesResolver < BaseResolver + type Types::Terraform::StateType, null: true + + alias_method :project, :object + + def resolve(**args) + return ::Terraform::State.none unless can_read_terraform_states? + + project.terraform_states.ordered_by_name + end + + private + + def can_read_terraform_states? + current_user.can?(:read_terraform_state, project) + end + end + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 0fd54af1538..0dff21cbd32 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -294,6 +294,12 @@ module Types description: 'Title of the label' end + field :terraform_states, + Types::Terraform::StateType.connection_type, + null: true, + description: 'Terraform states associated with the project', + resolver: Resolvers::Terraform::StatesResolver + def label(title:) BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args| LabelsFinder diff --git a/app/graphql/types/terraform/state_type.rb b/app/graphql/types/terraform/state_type.rb new file mode 100644 index 00000000000..f25f3a7789b --- /dev/null +++ b/app/graphql/types/terraform/state_type.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Types + module Terraform + class StateType < BaseObject + graphql_name 'TerraformState' + + authorize :read_terraform_state + + field :id, GraphQL::ID_TYPE, + null: false, + description: 'ID of the Terraform state' + + field :name, GraphQL::STRING_TYPE, + null: false, + description: 'Name of the Terraform state' + + field :locked_by_user, Types::UserType, + null: true, + authorize: :read_user, + description: 'The user currently holding a lock on the Terraform state', + resolve: -> (state, _, _) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, state.locked_by_user_id).find } + + field :locked_at, Types::TimeType, + null: true, + description: 'Timestamp the Terraform state was locked' + + field :created_at, Types::TimeType, + null: false, + description: 'Timestamp the Terraform state was created' + + field :updated_at, Types::TimeType, + null: false, + description: 'Timestamp the Terraform state was updated' + end + end +end diff --git a/app/helpers/feature_flags_helper.rb b/app/helpers/feature_flags_helper.rb new file mode 100644 index 00000000000..e50191a471f --- /dev/null +++ b/app/helpers/feature_flags_helper.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module FeatureFlagsHelper + include ::API::Helpers::RelatedResourcesHelpers + + def unleash_api_url(project) + expose_url(api_v4_feature_flags_unleash_path(project_id: project.id)) + end + + def unleash_api_instance_id(project) + project.feature_flags_client_token + end + + def feature_flag_issues_links_endpoint(_project, _feature_flag, _user) + '' + end +end + +FeatureFlagsHelper.prepend_if_ee('::EE::FeatureFlagsHelper') diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb index ce20442ff81..0a296b4e6ba 100644 --- a/app/helpers/packages_helper.rb +++ b/app/helpers/packages_helper.rb @@ -57,4 +57,9 @@ module PackagesHelper package_help_url: help_page_path('user/packages/index') } end + + def track_package_event(event_name, scope, **args) + ::Packages::CreateEventService.new(nil, current_user, event_name: event_name, scope: scope).execute + track_event(event_name, **args) + end end diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb index a19c3512f8d..79f4810e13a 100644 --- a/app/helpers/system_note_helper.rb +++ b/app/helpers/system_note_helper.rb @@ -2,6 +2,8 @@ module SystemNoteHelper ICON_NAMES_BY_ACTION = { + 'approved' => 'approval', + 'unapproved' => 'unapproval', 'cherry_pick' => 'cherry-pick-commit', 'commit' => 'commit', 'description' => 'pencil-square', diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index 7b4485376d4..b10e8547e86 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -81,13 +81,6 @@ module Mentionable end def store_mentions! - # if store_mentioned_users_to_db feature flag is not enabled then consider storing operation as succeeded - # because we wrap this method in transaction with with_transaction_returning_status, and we need the status to be - # successful if mentionable.save is successful. - # - # This line will get removed when we remove the feature flag. - return true unless store_mentioned_users_to_db_enabled? - refs = all_references(self.author) references = {} @@ -253,15 +246,6 @@ module Mentionable def model_user_mention user_mentions.where(note_id: nil).first_or_initialize end - - # We need this method to be checking that store_mentioned_users_to_db feature flag is enabled at the group level - # and not the project level as epics are defined at group level and we want to have epics store user mentions as well - # for the test period. - # During the test period the flag should be enabled at the group level. - def store_mentioned_users_to_db_enabled? - return Feature.enabled?(:store_mentioned_users_to_db, self.project&.group, default_enabled: true) if self.respond_to?(:project) - return Feature.enabled?(:store_mentioned_users_to_db, self.group, default_enabled: true) if self.respond_to?(:group) - end end Mentionable.prepend_if_ee('EE::Mentionable') diff --git a/app/models/event.rb b/app/models/event.rb index 92609144576..318599052e4 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -242,6 +242,8 @@ class Event < ApplicationRecord target if note? end + # rubocop: disable Metrics/CyclomaticComplexity + # rubocop: disable Metrics/PerceivedComplexity def action_name if push_action? push_action_name @@ -267,10 +269,14 @@ class Event < ApplicationRecord 'updated' elsif created_project_action? created_project_action_name + elsif approved_action? + 'approved' else "opened" end end + # rubocop: enable Metrics/CyclomaticComplexity + # rubocop: enable Metrics/PerceivedComplexity def target_iid target.respond_to?(:iid) ? target.iid : target_id diff --git a/app/models/packages/event.rb b/app/models/packages/event.rb new file mode 100644 index 00000000000..730ce267273 --- /dev/null +++ b/app/models/packages/event.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class Packages::Event < ApplicationRecord + belongs_to :package, optional: true + + # FIXME: Remove debian: 9 from here when it's added to the types in package.rb model + EVENT_SCOPES = ::Packages::Package.package_types.merge(debian: 9, container: 1000, tag: 1001).freeze + + enum event_scope: EVENT_SCOPES + + enum event_type: { + push_package: 0, + delete_package: 1, + pull_package: 2, + search_package: 3, + list_package: 4, + list_repositories: 5, + delete_repository: 6, + delete_tag: 7, + delete_tag_bulk: 8, + list_tags: 9, + cli_metadata: 10 + } + + enum originator_type: { user: 0, deploy_token: 1, guest: 2 } +end diff --git a/app/models/project.rb b/app/models/project.rb index 8934c09f7b1..ca3729e277a 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -337,6 +337,8 @@ class Project < ApplicationRecord has_many :webide_pipelines, -> { webide_source }, class_name: 'Ci::Pipeline', inverse_of: :project has_many :reviews, inverse_of: :project + has_many :terraform_states, class_name: 'Terraform::State', inverse_of: :project + # GitLab Pages has_many :pages_domains has_one :pages_metadatum, class_name: 'ProjectPagesMetadatum', inverse_of: :project @@ -2506,6 +2508,11 @@ class Project < ApplicationRecord GroupDeployKey.for_groups(group.self_and_ancestors_ids) end + def feature_flags_client_token + instance = operations_feature_flags_client || create_operations_feature_flags_client! + instance.token + end + private def find_service(services, name) diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb index 419fffcb666..7a43350971a 100644 --- a/app/models/terraform/state.rb +++ b/app/models/terraform/state.rb @@ -15,6 +15,7 @@ module Terraform has_one :latest_version, -> { ordered_by_version_desc }, class_name: 'Terraform::StateVersion', foreign_key: :terraform_state_id scope :versioning_not_enabled, -> { where(versioning_enabled: false) } + scope :ordered_by_name, -> { order(:name) } validates :project_id, presence: true validates :uuid, presence: true, uniqueness: true, length: { is: UUID_LENGTH }, diff --git a/app/policies/terraform/state_policy.rb b/app/policies/terraform/state_policy.rb new file mode 100644 index 00000000000..ba6109e5975 --- /dev/null +++ b/app/policies/terraform/state_policy.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Terraform + class StatePolicy < BasePolicy + alias_method :terraform_state, :subject + + delegate { terraform_state.project } + end +end diff --git a/app/services/packages/create_event_service.rb b/app/services/packages/create_event_service.rb new file mode 100644 index 00000000000..d009cba2812 --- /dev/null +++ b/app/services/packages/create_event_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Packages + class CreateEventService < BaseService + def execute + event_scope = scope.is_a?(::Packages::Package) ? scope.package_type : scope + + ::Packages::Event.create!( + event_type: event_name, + originator: current_user&.id, + originator_type: originator_type, + event_scope: event_scope + ) + end + + private + + def scope + params[:scope] + end + + def event_name + params[:event_name] + end + + def originator_type + case current_user + when User + :user + when DeployToken + :deploy_token + else + :guest + end + end + end +end diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml index 54f6fa91cf1..8c23fc7c590 100644 --- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml +++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml @@ -1,11 +1,9 @@ - link = link_to(s_('ClusterIntegration|sign up'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer') -.bs-callout.gcp-signup-offer.alert.alert-block.alert-dismissable.gl-mt-3.gl-mb-3{ role: 'alert', data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } } - %button.close.js-close{ type: "button" } × - .gcp-signup-offer--content - .gcp-signup-offer--icon.gl-mr-3 - = sprite_icon("information") - .gcp-signup-offer--copy - %h4= s_('ClusterIntegration|Did you know?') - %p= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link } - %a.btn.btn-default{ href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', rel: 'noopener noreferrer' } - = s_("ClusterIntegration|Apply for credit") +.gcp-signup-offer.gl-alert.gl-alert-info.gl-my-3{ role: 'alert', data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } } + %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } + = sprite_icon('close', size: 16, css_class: 'gl-icon') + = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + %h4.gl-alert-title= s_('ClusterIntegration|Did you know?') + %p.gl-alert-body= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link } + %a.gl-button.btn-info{ href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', rel: 'noopener noreferrer' } + = s_("ClusterIntegration|Apply for credit") diff --git a/app/views/invites/decline.html.haml b/app/views/invites/decline.html.haml new file mode 100644 index 00000000000..4a57d70cb6e --- /dev/null +++ b/app/views/invites/decline.html.haml @@ -0,0 +1,8 @@ +- page_title _('Invitation declined') +.decline-page.gl-display-flex.gl-flex-direction-column.gl-mx-auto{ class: 'gl-xs-w-full!' } + .gl-align-self-center.gl-mb-4.gl-mt-7.gl-sm-mt-0= sprite_icon('check-circle', size: 48, css_class: 'gl-text-green-400') + %h2.gl-font-size-h2= _('You successfully declined the invitation') + %p + = html_escape(_('We will notify %{inviter} that you declined their invitation to join GitLab. You will stop receiving reminders.')) % { inviter: sanitize_name(@member.created_by.name) } + %p + = _('You can now close this window.') diff --git a/app/views/projects/group_links/update.js.haml b/app/views/projects/group_links/update.js.haml deleted file mode 100644 index 55520fda494..00000000000 --- a/app/views/projects/group_links/update.js.haml +++ /dev/null @@ -1,4 +0,0 @@ -:plain - var $listItem = $('#{escape_javascript(render('shared/members/group', group_link: @group_link))}'); - $("#group_member_#{@group_link.id} .list-item-name").replaceWith($listItem.find('.list-item-name')); - gl.utils.localTimeAgo($('.js-timeago'), $("#group_member_#{@group_link.id}")); diff --git a/app/views/shared/members/_group.html.haml b/app/views/shared/members/_group.html.haml index 8e5763842d9..cd24942616c 100644 --- a/app/views/shared/members/_group.html.haml +++ b/app/views/shared/members/_group.html.haml @@ -6,17 +6,18 @@ -# Note this is just for groups. For individual members please see shared/members/_member -%li.member.group_member.py-2.px-3.d-flex.flex-column.flex-md-row{ id: dom_id, data: { qa_selector: 'group_row' } } +%li.member.js-member.group_member.py-2.px-3.d-flex.flex-column.flex-md-row{ id: dom_id, data: { qa_selector: 'group_row' } } %span.list-item-name.mb-2.m-md-0 = group_icon(group, class: "avatar s40 flex-shrink-0 flex-grow-0", alt: '') .user-info = link_to group.full_name, group_path(group), class: 'member' .cgray Given access #{time_ago_with_tooltip(group_link.created_at)} - - if group_link.expires? - ยท - %span{ class: ('text-warning' if group_link.expires_soon?) } - = _("Expires in %{expires_at}").html_safe % { expires_at: distance_of_time_in_words_to_now(group_link.expires_at) } + %span.js-expires-in{ class: ('gl-display-none' unless group_link.expires?) } + · + %span.js-expires-in-text{ class: ('text-warning' if group_link.expires_soon?) } + - if group_link.expires? + = _("Expires in %{expires_at}").html_safe % { expires_at: distance_of_time_in_words_to_now(group_link.expires_at) } .controls.member-controls.align-items-center = form_tag group_link_path, method: :put, remote: true, class: 'js-edit-member-form form-group d-sm-flex' do = hidden_field_tag "group_link[group_access]", group_link.group_access diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index 7573c2f6d56..679a460eeb3 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -8,7 +8,7 @@ -# Note this is just for individual members. For groups please see shared/members/_group -%li.member.py-2.px-3.d-flex.flex-column{ class: [dom_class(member), ("is-overridden" if override), ("flex-md-row" unless force_mobile_view)], id: dom_id(member), data: { qa_selector: 'member_row' } } +%li.member.js-member.py-2.px-3.d-flex.flex-column{ class: [dom_class(member), ("is-overridden" if override), ("flex-md-row" unless force_mobile_view)], id: dom_id(member), data: { qa_selector: 'member_row' } } %span.list-item-name.mb-2.m-md-0 - if user = image_tag avatar_icon_for_user(user, 40), class: "avatar s40 flex-shrink-0 flex-grow-0", alt: '' diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml index 0c26cb533a5..c325e8d4a16 100644 --- a/app/views/shared/snippets/_form.html.haml +++ b/app/views/shared/snippets/_form.html.haml @@ -10,7 +10,7 @@ .form-group = f.label :title, class: 'label-bold' - = f.text_field :title, class: 'form-control', required: true, autofocus: true, data: { qa_selector: 'snippet_title_field' } + = f.text_field :title, class: 'form-control', required: true, autofocus: true .form-group.js-description-input - description_placeholder = s_('Snippets|Optionally add a description about what your snippet does or how to use it...') @@ -18,17 +18,17 @@ = f.label :description, s_("Snippets|Description (optional)"), class: 'label-bold' .js-collapsible-input .js-collapsed{ class: ('d-none' if is_expanded) } - = text_field_tag nil, nil, class: 'form-control', placeholder: description_placeholder, data: { qa_selector: 'description_placeholder' } + = text_field_tag nil, nil, class: 'form-control', placeholder: description_placeholder .js-expanded{ class: ('d-none' if !is_expanded) } = render layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do - = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: description_placeholder, qa_selector: 'snippet_description_field' + = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: description_placeholder = render 'shared/notes/hints' .form-group.file-editor = f.label :file_name, s_('Snippets|File') .file-holder.snippet .js-file-title.file-title-flex-parent - = f.text_field :file_name, placeholder: s_("Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby"), class: 'form-control js-snippet-file-name', data: { qa_selector: 'file_name_field' } + = f.text_field :file_name, placeholder: s_("Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby"), class: 'form-control js-snippet-file-name' .file-content.code #editor{ data: { 'editor-loading': true } }< %pre.editor-loading-content= @snippet.content @@ -46,9 +46,9 @@ .form-actions - if @snippet.new_record? - = f.submit 'Create snippet', class: "btn-success btn gl-button", data: { qa_selector: 'submit_button' } + = f.submit 'Create snippet', class: "btn-success btn gl-button" - else - = f.submit 'Save changes', class: "btn-success btn gl-button", data: { qa_selector: 'submit_button' } + = f.submit 'Save changes', class: "btn-success btn gl-button" - if @snippet.project_id = link_to "Cancel", project_snippets_path(@project), class: "btn gl-button btn-default" |