Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-11-19 21:12:59 +00:00
parent 78f7d2e726
commit 652d8b33ee
8 changed files with 591 additions and 75 deletions

View File

@ -82,7 +82,7 @@ In this rollout issue, ensure the scoped `experiment::` label is kept accurate.
## Roll Out Steps
- [ ] Confirm that QA tests pass with the feature flag enabled (if you're unsure how, contact the relevant [stable counterpart in the Quality department](https://about.gitlab.com/handbook/engineering/quality/#individual-contributors))
- [ ] [Confirm that end-to-end tests pass with the feature flag enabled](https://docs.gitlab.com/ee/development/testing_guide/end_to_end/feature_flags.html#confirming-that-end-to-end-tests-pass-with-a-feature-flag-enabled). If there are failing tests, contact the relevant [stable counterpart in the Quality department](https://about.gitlab.com/handbook/engineering/quality/#individual-contributors) to collaborate in updating the tests or confirming that the failing tests are not caused by the changes behind the enabled feature flag.
- [ ] Enable on staging (`/chatops run feature set <experiment-key> true --staging`)
- [ ] Test on staging
- [ ] Ensure that documentation has been updated

View File

@ -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>

View File

@ -16,94 +16,81 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> - Overview table [added](https://gitlab.com/gitlab-org/gitlab/-/issues/335638) in GitLab 14.3.
> - Adoption over time chart [added](https://gitlab.com/gitlab-org/gitlab/-/issues/337561) in GitLab 14.4.
Prerequisites:
DevOps Adoption shows you how groups in your organization adopt and use the most essential features of GitLab.
- You must have at least the [Reporter role](../../permissions.md) for the group.
You can use Group DevOps Adoption to:
To access Group DevOps Adoption, go to your group and select **Analytics > DevOps Adoption**.
Group DevOps Adoption shows you how individual groups and subgroups within your organization use the following features:
- Dev
- Approvals
- Code owners
- Issues
- Merge requests
- Sec
- DAST
- Dependency Scanning
- Fuzz Testing
- SAST
- Ops
- Deployments
- Pipelines
- Runners
When managing groups in the UI, you can add or remove your subgroups with the **Add or remove subgroups**
button, in the top right hand section of your Groups pages.
With DevOps Adoption you can:
- Verify whether you are getting the return on investment that you expected from GitLab.
- Identify specific subgroups that are lagging in their adoption of GitLab, so you can help them along in their DevOps journey.
- Find the subgroups that have adopted certain features, and can provide guidance to other subgroups on how to use those features.
- Identify specific subgroups that are lagging in their adoption of GitLab features, so you can guide them on
their DevOps journey.
- Find subgroups that have adopted certain features, and provide guidance to other subgroups on
how to use those features.
- Verify if you are getting the return on investment that you expected from GitLab.
![DevOps Adoption](img/group_devops_adoption_v14_2.png)
Feature adoption is based on usage in the previous calendar month. Data is updated on the first day
of each month. If the monthly update fails, it tries again daily until successful.
## View DevOps Adoption
## Enable data processing
Prerequisite:
Group DevOps Adoption relies on data that has been gathered by a weekly data processing task.
This task is disabled by default.
- You must have at least the [Reporter role](../../permissions.md) for the group.
To begin using Group DevOps Adoption, access the feature for the first time. GitLab automatically
enables the data processing for that group. The group data doesn't appear immediately, because
GitLab requires around a minute to process it.
To view DevOps Adoption:
## What is displayed
1. On the top bar, select **Menu > Groups** and find your group.
1. On the left sidebar, select **Analytics > DevOps adoption**
DevOps Adoption displays feature adoption data for the given group
and any added subgroups for the current calendar month.
Each group appears as a separate row in the table.
For each row, a feature is considered "adopted" if it has been used in a project in the given group
during the time period (including projects in any subgroups of the given group).
## DevOps Adoption categories
## Adoption over time
DevOps Adoption shows feature adoption for development, security, and operations.
The **Adoption over time** chart in the **Overview** tab displays DevOps Adoption over time. The chart displays the total number of adopted features from the previous twelve months,
from when you enabled DevOps Adoption for the group.
| Category | Feature |
| --- | --- |
| Development | Approvals<br>Code owners<br>Issues<br>Merge requests |
| Security | DAST<br>Dependency Scanning<br>Fuzz Testing<br>SAST |
| Operations | Deployments<br>Pipelines<br>Runners |
The tooltip displays information about the features tracked for individual months.
## Feature adoption
## When is a feature considered adopted
DevOps Adoption shows feature adoption data for groups and subgroups for the previous calendar month.
A feature is considered "adopted" if it has been used anywhere in the group in the specified time.
For example, if an issue was created in one project in a group, the group is considered to have
"adopted" issues in that time.
A feature shows as **adopted** when a group has used the feature in a project during the time period.
This includes projects in any subgroups of the group. For example, if an issue was created in a project in a group, the group has adopted issues in that time.
## No penalties for common adoption patterns
### Exceptions to feature adoption data
DevOps Adoption is designed not to penalize for any circumstances or practices that are common in DevOps.
Following this guideline, GitLab doesn't penalize for:
When GitLab measures DevOps Adoption, some common DevOps information is not included:
1. Having dormant projects. It's common for groups to have a mix of active and dormant projects,
so we should not consider adoption to be low if there are relatively many dormant projects.
This means we should not measure adoption by how many projects in the group have used a feature,
only by whether a feature was used anywhere in the group.
1. GitLab adding new features over time. It's common for group feature usage to be consistent
over time, so we should not consider adoption to have decreased if GitLab adds features.
This means we should not measure adoption by percentages, only total counts.
- Dormant projects. It doesn't matter how many projects in the group use a feature. Even if you have many dormant projects, it doesn't lower the adoption.
- New GitLab features. Adoption is the total number of features adopted, not the percent of features.
## Add a subgroup
## When DevOps Adoption data is gathered
DevOps Adoption can also display data for subgroups within the given group,
to show you differences in adoption across the group.
To add subgroups to your Group DevOps Adoption report:
A weekly task processes data for DevOps Adoption. This task is disabled until you access
DevOps Adoption for a group for the first time.
The data processing task updates the data on the first day of each month. If the monthly update
fails, the task tries daily until it succeeds.
DevOps Adoption data may take up to a minute to appear while GitLab processes the group's data.
## View feature adoption over time
The **Adoption over time** chart shows the total number of adopted features from the previous
twelve months. The chart only shows data from when you enabled DevOps Adoption for the group.
To view feature adoption over time:
1. On the top bar, select **Menu > Groups** and find your group.
1. On the left sidebar, select **Analytics > DevOps adoption**.
1. Select the **Overview** tab.
Tooltips display information about the features tracked for individual months.
## Add or remove a subgroup
To add or remove a subgroup from the DevOps Adoption report:
1. Select **Add or remove subgroups**.
1. Select the subgroups you want to add and select **Save changes**.
1. Select the subgroup you want to add or remove and select **Save changes**.
The subgroup data might not appear immediately, because GitLab requires around a minute to collect
the data.
It may take up to a minute for subgroup data to appear while GitLab collects the data.

View File

@ -3917,6 +3917,9 @@ msgstr ""
msgid "An example showing how to use Jsonnet with GitLab dynamic child pipelines"
msgstr ""
msgid "An integer value is required for seconds"
msgstr ""
msgid "An issue already exists"
msgstr ""
@ -25974,6 +25977,9 @@ msgstr ""
msgid "Please enter a valid number"
msgstr ""
msgid "Please enter a valid time interval"
msgstr ""
msgid "Please enter or upload a valid license."
msgstr ""

View File

@ -57,7 +57,7 @@
"@gitlab/favicon-overlay": "2.0.0",
"@gitlab/svgs": "1.221.0",
"@gitlab/tributejs": "1.0.0",
"@gitlab/ui": "32.38.0",
"@gitlab/ui": "32.39.0",
"@gitlab/visual-review-tools": "1.6.1",
"@rails/actioncable": "6.1.4-1",
"@rails/ujs": "6.1.4-1",

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module QA
RSpec.describe 'Create' do
RSpec.describe 'Create', quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/346149', type: :stale } do
context 'Content Editor' do
let(:initial_wiki) { Resource::Wiki::ProjectPage.fabricate_via_api! }
let(:page_title) { 'Content Editor Page' }

View File

@ -0,0 +1,390 @@
import { mount } from '@vue/test-utils';
import ChronicDurationInput from '~/vue_shared/components/chronic_duration_input.vue';
const MOCK_VALUE = 2 * 3600 + 20 * 60;
describe('vue_shared/components/chronic_duration_input', () => {
let wrapper;
let textElement;
let hiddenElement;
afterEach(() => {
wrapper.destroy();
wrapper = null;
textElement = null;
hiddenElement = null;
});
const findComponents = () => {
textElement = wrapper.find('input[type=text]').element;
hiddenElement = wrapper.find('input[type=hidden]').element;
};
const createComponent = (props = {}) => {
if (wrapper) {
throw new Error('There should only be one wrapper created per test');
}
wrapper = mount(ChronicDurationInput, { propsData: props });
findComponents();
};
describe('value', () => {
it('has human-readable output with value', () => {
createComponent({ value: MOCK_VALUE });
expect(textElement.value).toBe('2 hrs 20 mins');
expect(hiddenElement.value).toBe(MOCK_VALUE.toString());
});
it('has empty output with no value', () => {
createComponent({ value: null });
expect(textElement.value).toBe('');
expect(hiddenElement.value).toBe('');
});
});
describe('change', () => {
const createAndDispatch = async (initialValue, humanReadableInput) => {
createComponent({ value: initialValue });
await wrapper.vm.$nextTick();
textElement.value = humanReadableInput;
textElement.dispatchEvent(new Event('input'));
};
describe('when starting with no value and receiving human-readable input', () => {
beforeEach(() => {
createAndDispatch(null, '2hr20min');
});
it('updates hidden field', () => {
expect(textElement.value).toBe('2hr20min');
expect(hiddenElement.value).toBe(MOCK_VALUE.toString());
});
it('emits change event', () => {
expect(wrapper.emitted('change')).toEqual([[MOCK_VALUE]]);
});
});
describe('when starting with a value and receiving empty input', () => {
beforeEach(() => {
createAndDispatch(MOCK_VALUE, '');
});
it('updates hidden field', () => {
expect(textElement.value).toBe('');
expect(hiddenElement.value).toBe('');
});
it('emits change event', () => {
expect(wrapper.emitted('change')).toEqual([[null]]);
});
});
describe('when starting with a value and receiving invalid input', () => {
beforeEach(() => {
createAndDispatch(MOCK_VALUE, 'gobbledygook');
});
it('does not update hidden field', () => {
expect(textElement.value).toBe('gobbledygook');
expect(hiddenElement.value).toBe(MOCK_VALUE.toString());
});
it('does not emit change event', () => {
expect(wrapper.emitted('change')).toBeUndefined();
});
});
});
describe('valid', () => {
describe('initial value', () => {
beforeEach(() => {
createComponent({ value: MOCK_VALUE });
});
it('emits valid with initial value', () => {
expect(wrapper.emitted('valid')).toEqual([[{ valid: true, feedback: '' }]]);
expect(textElement.validity.valid).toBe(true);
expect(textElement.validity.customError).toBe(false);
expect(textElement.validationMessage).toBe('');
expect(hiddenElement.validity.valid).toBe(true);
expect(hiddenElement.validity.customError).toBe(false);
expect(hiddenElement.validationMessage).toBe('');
});
it('emits valid with user input', async () => {
textElement.value = '1m10s';
textElement.dispatchEvent(new Event('input'));
await wrapper.vm.$nextTick();
expect(wrapper.emitted('valid')).toEqual([
[{ valid: true, feedback: '' }],
[{ valid: true, feedback: '' }],
]);
expect(textElement.validity.valid).toBe(true);
expect(textElement.validity.customError).toBe(false);
expect(textElement.validationMessage).toBe('');
expect(hiddenElement.validity.valid).toBe(true);
expect(hiddenElement.validity.customError).toBe(false);
expect(hiddenElement.validationMessage).toBe('');
textElement.value = '';
textElement.dispatchEvent(new Event('input'));
await wrapper.vm.$nextTick();
expect(wrapper.emitted('valid')).toEqual([
[{ valid: true, feedback: '' }],
[{ valid: true, feedback: '' }],
[{ valid: null, feedback: '' }],
]);
expect(textElement.validity.valid).toBe(true);
expect(textElement.validity.customError).toBe(false);
expect(textElement.validationMessage).toBe('');
expect(hiddenElement.validity.valid).toBe(true);
expect(hiddenElement.validity.customError).toBe(false);
expect(hiddenElement.validationMessage).toBe('');
});
it('emits invalid with user input', async () => {
textElement.value = 'gobbledygook';
textElement.dispatchEvent(new Event('input'));
await wrapper.vm.$nextTick();
expect(wrapper.emitted('valid')).toEqual([
[{ valid: true, feedback: '' }],
[{ valid: false, feedback: ChronicDurationInput.i18n.INVALID_INPUT_FEEDBACK }],
]);
expect(textElement.validity.valid).toBe(false);
expect(textElement.validity.customError).toBe(true);
expect(textElement.validationMessage).toBe(
ChronicDurationInput.i18n.INVALID_INPUT_FEEDBACK,
);
expect(hiddenElement.validity.valid).toBe(false);
expect(hiddenElement.validity.customError).toBe(true);
// Hidden elements do not have validationMessage
expect(hiddenElement.validationMessage).toBe('');
});
});
describe('no initial value', () => {
beforeEach(() => {
createComponent({ value: null });
});
it('emits valid with no initial value', () => {
expect(wrapper.emitted('valid')).toEqual([[{ valid: null, feedback: '' }]]);
expect(textElement.validity.valid).toBe(true);
expect(textElement.validity.customError).toBe(false);
expect(textElement.validationMessage).toBe('');
expect(hiddenElement.validity.valid).toBe(true);
expect(hiddenElement.validity.customError).toBe(false);
expect(hiddenElement.validationMessage).toBe('');
});
it('emits valid with updated value', async () => {
wrapper.setProps({ value: MOCK_VALUE });
await wrapper.vm.$nextTick();
expect(wrapper.emitted('valid')).toEqual([
[{ valid: null, feedback: '' }],
[{ valid: true, feedback: '' }],
]);
expect(textElement.validity.valid).toBe(true);
expect(textElement.validity.customError).toBe(false);
expect(textElement.validationMessage).toBe('');
expect(hiddenElement.validity.valid).toBe(true);
expect(hiddenElement.validity.customError).toBe(false);
expect(hiddenElement.validationMessage).toBe('');
});
});
describe('decimal input', () => {
describe('when integerRequired is false', () => {
beforeEach(() => {
createComponent({ value: null, integerRequired: false });
});
it('emits valid when input is integer', async () => {
textElement.value = '2hr20min';
textElement.dispatchEvent(new Event('input'));
await wrapper.vm.$nextTick();
expect(wrapper.emitted('change')).toEqual([[MOCK_VALUE]]);
expect(wrapper.emitted('valid')).toEqual([
[{ valid: null, feedback: '' }],
[{ valid: true, feedback: '' }],
]);
expect(textElement.validity.valid).toBe(true);
expect(textElement.validity.customError).toBe(false);
expect(textElement.validationMessage).toBe('');
expect(hiddenElement.validity.valid).toBe(true);
expect(hiddenElement.validity.customError).toBe(false);
expect(hiddenElement.validationMessage).toBe('');
});
it('emits valid when input is decimal', async () => {
textElement.value = '1.5s';
textElement.dispatchEvent(new Event('input'));
await wrapper.vm.$nextTick();
expect(wrapper.emitted('change')).toEqual([[1.5]]);
expect(wrapper.emitted('valid')).toEqual([
[{ valid: null, feedback: '' }],
[{ valid: true, feedback: '' }],
]);
expect(textElement.validity.valid).toBe(true);
expect(textElement.validity.customError).toBe(false);
expect(textElement.validationMessage).toBe('');
expect(hiddenElement.validity.valid).toBe(true);
expect(hiddenElement.validity.customError).toBe(false);
expect(hiddenElement.validationMessage).toBe('');
});
});
describe('when integerRequired is unspecified', () => {
beforeEach(() => {
createComponent({ value: null });
});
it('emits valid when input is integer', async () => {
textElement.value = '2hr20min';
textElement.dispatchEvent(new Event('input'));
await wrapper.vm.$nextTick();
expect(wrapper.emitted('change')).toEqual([[MOCK_VALUE]]);
expect(wrapper.emitted('valid')).toEqual([
[{ valid: null, feedback: '' }],
[{ valid: true, feedback: '' }],
]);
expect(textElement.validity.valid).toBe(true);
expect(textElement.validity.customError).toBe(false);
expect(textElement.validationMessage).toBe('');
expect(hiddenElement.validity.valid).toBe(true);
expect(hiddenElement.validity.customError).toBe(false);
expect(hiddenElement.validationMessage).toBe('');
});
it('emits invalid when input is decimal', async () => {
textElement.value = '1.5s';
textElement.dispatchEvent(new Event('input'));
await wrapper.vm.$nextTick();
expect(wrapper.emitted('change')).toBeUndefined();
expect(wrapper.emitted('valid')).toEqual([
[{ valid: null, feedback: '' }],
[
{
valid: false,
feedback: ChronicDurationInput.i18n.INVALID_DECIMAL_FEEDBACK,
},
],
]);
expect(textElement.validity.valid).toBe(false);
expect(textElement.validity.customError).toBe(true);
expect(textElement.validationMessage).toBe(
ChronicDurationInput.i18n.INVALID_DECIMAL_FEEDBACK,
);
expect(hiddenElement.validity.valid).toBe(false);
expect(hiddenElement.validity.customError).toBe(true);
// Hidden elements do not have validationMessage
expect(hiddenElement.validationMessage).toBe('');
});
});
});
});
describe('v-model', () => {
beforeEach(() => {
wrapper = mount({
data() {
return { value: 1 * 60 + 10 };
},
components: { ChronicDurationInput },
template: '<div><chronic-duration-input v-model="value"/></div>',
});
findComponents();
});
describe('value', () => {
it('passes initial prop via v-model', () => {
expect(textElement.value).toBe('1 min 10 secs');
expect(hiddenElement.value).toBe((1 * 60 + 10).toString());
});
it('passes updated prop via v-model', async () => {
wrapper.setData({ value: MOCK_VALUE });
await wrapper.vm.$nextTick();
expect(textElement.value).toBe('2 hrs 20 mins');
expect(hiddenElement.value).toBe(MOCK_VALUE.toString());
});
});
describe('change', () => {
it('passes user input to parent via v-model', async () => {
textElement.value = '2hr20min';
textElement.dispatchEvent(new Event('input'));
await wrapper.vm.$nextTick();
expect(wrapper.findComponent(ChronicDurationInput).props('value')).toBe(MOCK_VALUE);
expect(textElement.value).toBe('2hr20min');
expect(hiddenElement.value).toBe(MOCK_VALUE.toString());
});
});
});
describe('name', () => {
beforeEach(() => {
createComponent({ name: 'myInput' });
});
it('sets name of hidden field', () => {
expect(hiddenElement.name).toBe('myInput');
});
it('does not set name of text field', () => {
expect(textElement.name).toBe('');
});
});
describe('form submission', () => {
beforeEach(() => {
wrapper = mount({
template: `<form data-testid="myForm"><chronic-duration-input name="myInput" :value="${MOCK_VALUE}"/></form>`,
components: {
ChronicDurationInput,
},
});
findComponents();
});
it('creates form data with initial value', () => {
const formData = new FormData(wrapper.find('[data-testid=myForm]').element);
const iter = formData.entries();
expect(iter.next()).toEqual({
value: ['myInput', MOCK_VALUE.toString()],
done: false,
});
expect(iter.next()).toEqual({ value: undefined, done: true });
});
it('creates form data with user-specified value', async () => {
textElement.value = '1m10s';
textElement.dispatchEvent(new Event('input'));
await wrapper.vm.$nextTick();
const formData = new FormData(wrapper.find('[data-testid=myForm]').element);
const iter = formData.entries();
expect(iter.next()).toEqual({
value: ['myInput', (1 * 60 + 10).toString()],
done: false,
});
expect(iter.next()).toEqual({ value: undefined, done: true });
});
});
});

View File

@ -924,10 +924,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/tributejs/-/tributejs-1.0.0.tgz#672befa222aeffc83e7d799b0500a7a4418e59b8"
integrity sha512-nmKw1+hB6MHvlmPz63yPwVs1qQkycHwsKgxpEbzmky16Y6mL4EJMk3w1b8QlOAF/AIAzjCERPhe/R4MJiohbZw==
"@gitlab/ui@32.38.0":
version "32.38.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-32.38.0.tgz#580bda8daacdf23c1f6a75d77f6df44b1dbe1726"
integrity sha512-BS0+4JicfuiCbaWTTok0dQUzUCI8m8t5T7//DQUQqqwCZLYeJlb1AxMatd6IjwcdE0m+AhST3iOZi2x+hDrkbQ==
"@gitlab/ui@32.39.0":
version "32.39.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-32.39.0.tgz#81dd3fd730721e828f62f6aff0a3f41afc3ac309"
integrity sha512-SgbmTkjpMFoTGREkJM7D7/VnzQjdmAikF45pqQJVJWZtGSLAadIbuZQBFUYwa9SFC6BhXjeoSmyRxBr1sdB19A==
dependencies:
"@babel/standalone" "^7.0.0"
bootstrap-vue "2.20.1"