Commit 32edb533 authored by Vitaly Slobodin's avatar Vitaly Slobodin

Merge branch '267536-vsa-create-form-from-default' into 'master'

Preconfigure fields for a default value stream

See merge request gitlab-org/gitlab!49094
parents 97811a03 26ff399c
......@@ -5,9 +5,10 @@ export const NAME_MAX_LENGTH = 100;
export const I18N = {
FORM_TITLE: __('Create Value Stream'),
FORM_CREATED: __("'%{name}' Value Stream created"),
FORM_CREATED: s__("CreateValueStreamForm|'%{name}' Value Stream created"),
RECOVER_HIDDEN_STAGE: s__('CreateValueStreamForm|Recover hidden stage'),
RESTORE_HIDDEN_STAGE: s__('CreateValueStreamForm|Restore stage'),
RESTORE_STAGES: s__('CreateValueStreamForm|Restore defaults'),
RECOVER_STAGE_TITLE: s__('CreateValueStreamForm|Default stages'),
RECOVER_STAGES_VISIBLE: s__('CreateValueStreamForm|All default stages are currently visible'),
SELECT_START_EVENT: s__('CreateValueStreamForm|Select start event'),
......@@ -65,15 +66,49 @@ export const defaultFields = {
endEventLabelId: null,
};
export const DEFAULT_STAGE_CONFIG = ['issue', 'plan', 'code', 'test', 'review', 'staging'].map(
(id) => ({
id,
const defaultStageCommonFields = { custom: false, hidden: false };
/**
* These stage configs are copied from the https://gitlab.com/gitlab-org/gitlab/-/tree/master/lib/gitlab/cycle_analytics
* This is a stopgap solution and we should eventually provide these from an API endpoint
*
* More information: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/49094#note_464116439
*/
const BASE_DEFAULT_STAGE_CONFIG = [
{
id: 'issue',
startEventIdentifier: ['issue_created'],
endEventIdentifier: ['issue_first_associated_with_milestone', 'issue_first_added_to_board'],
},
{
id: 'plan',
startEventIdentifier: ['issue_first_associated_with_milestone', 'issue_first_added_to_board'],
endEventIdentifier: ['issue_first_mentioned_in_commit'],
},
{
id: 'code',
startEventIdentifier: ['issue_first_mentioned_in_commit'],
endEventIdentifier: ['merge_request_created'],
},
{
id: 'test',
startEventIdentifier: ['merge_request_last_build_started'],
endEventIdentifier: ['merge_request_last_build_finished'],
},
{
id: 'review',
startEventIdentifier: ['merge_request_created'],
endEventIdentifier: ['merge_request_merged'],
},
{
id: 'staging',
startEventIdentifier: ['merge_request_merged'],
endEventIdentifier: ['merge_request_first_deployed_to_production'],
},
];
export const DEFAULT_STAGE_CONFIG = BASE_DEFAULT_STAGE_CONFIG.map(({ id, ...rest }) => ({
...rest,
...defaultStageCommonFields,
name: capitalizeFirstCharacter(id),
startEventIdentifier: null,
endEventIdentifier: null,
startEventLabel: null,
endEventLabel: null,
custom: false,
hidden: false,
}),
);
}));
......@@ -3,6 +3,19 @@ import { GlFormGroup, GlFormInput, GlFormText } from '@gitlab/ui';
import StageFieldActions from './stage_field_actions.vue';
import { I18N } from './constants';
const findStageEvent = (stageEvents = [], eid = null) => {
if (!eid) return '';
return stageEvents.find(({ identifier }) => identifier === eid);
};
const eventIdsToName = (stageEvents = [], eventIds = []) =>
eventIds
.map((eid) => {
const stage = findStageEvent(stageEvents, eid);
return stage?.name || '';
})
.join(', ');
export default {
name: 'DefaultStageFields',
components: {
......@@ -29,6 +42,10 @@ export default {
required: false,
default: () => {},
},
stageEvents: {
type: Array,
required: true,
},
},
methods: {
isValid(field) {
......@@ -37,6 +54,9 @@ export default {
renderError(field) {
return this.errors[field]?.join('\n');
},
eventName(eventIds = []) {
return eventIdsToName(this.stageEvents, eventIds);
},
},
I18N,
};
......@@ -65,19 +85,19 @@ export default {
/>
</div>
<div class="gl-display-flex" :data-testid="`stage-start-event-${index}`">
<span class="gl-m-0 gl-vertical-align-middle gl-mr-3 gl-font-weight-bold">{{
<span class="gl-m-0 gl-vertical-align-middle gl-mr-2 gl-font-weight-bold">{{
$options.I18N.DEFAULT_FIELD_START_EVENT_LABEL
}}</span>
<gl-form-text>{{ stage.startEventIdentifier }}</gl-form-text>
<gl-form-text>{{ eventName(stage.startEventIdentifier) }}</gl-form-text>
<gl-form-text v-if="stage.startEventLabel"
>&nbsp;-&nbsp;{{ stage.startEventLabel }}</gl-form-text
>
</div>
<div class="gl-display-flex" :data-testid="`stage-end-event-${index}`">
<span class="gl-m-0 gl-vertical-align-middle gl-mr-3 gl-font-weight-bold">{{
$options.I18N.DEFAULT_FIELD_START_EVENT_LABEL
<span class="gl-m-0 gl-vertical-align-middle gl-mr-2 gl-font-weight-bold">{{
$options.I18N.DEFAULT_FIELD_END_EVENT_LABEL
}}</span>
<gl-form-text>{{ stage.endEventIdentifier }}</gl-form-text>
<gl-form-text>{{ eventName(stage.endEventIdentifier) }}</gl-form-text>
<gl-form-text v-if="stage.endEventLabel">&nbsp;-&nbsp;{{ stage.endEventLabel }}</gl-form-text>
</div>
</div>
......
import { isStartEvent, getAllowedEndEvents, eventToOption, eventsByIdentifier } from '../../utils';
import { I18N, ERRORS, defaultErrors, defaultFields } from './constants';
import { I18N, ERRORS, defaultErrors, defaultFields, NAME_MAX_LENGTH } from './constants';
import { DEFAULT_STAGE_NAMES } from '../../constants';
/**
......@@ -82,14 +82,21 @@ export const initializeFormData = ({ fields, errors }) => {
* @param {Object} fields key value pair of form field values
* @returns {Object} key value pair of form fields with an array of errors
*/
export const validateFields = (fields) => {
export const validateStage = (fields) => {
const newErrors = {};
if (fields?.name) {
newErrors.name = DEFAULT_STAGE_NAMES.includes(fields?.name.toLowerCase())
if (fields.name.length > NAME_MAX_LENGTH) {
newErrors.name = [ERRORS.MAX_LENGTH];
} else {
newErrors.name =
fields?.custom && DEFAULT_STAGE_NAMES.includes(fields.name.toLowerCase())
? [ERRORS.STAGE_NAME_EXISTS]
: [];
}
} else {
newErrors.name = [ERRORS.MIN_LENGTH];
}
if (fields?.startEventIdentifier) {
newErrors.endEventIdentifier = [];
......@@ -102,3 +109,21 @@ export const validateFields = (fields) => {
}
return newErrors;
};
/**
* Validates the name of a value stream Any errors will be
* returned as an array in a object with key`name`
*
* @param {Object} fields key value pair of form field values
* @returns {Object} key value pair of form fields with an array of errors
*/
export const validateValueStreamName = ({ name = '' }) => {
const errors = { name: [] };
if (name.length > NAME_MAX_LENGTH) {
errors.name.push(ERRORS.MAX_LENGTH);
}
if (!name.length) {
errors.name.push(ERRORS.MIN_LENGTH);
}
return errors;
};
......@@ -11,7 +11,7 @@ import {
} from '@gitlab/ui';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import CustomStageFormFields from './create_value_stream_form/custom_stage_fields.vue';
import { validateFields, initializeFormData } from './create_value_stream_form/utils';
import { validateStage, initializeFormData } from './create_value_stream_form/utils';
import { defaultFields, ERRORS, I18N } from './create_value_stream_form/constants';
import { STAGE_ACTIONS } from '../constants';
import { getAllowedEndEvents, getLabelEventsIdentifiers, isLabelEvent } from '../utils';
......@@ -157,7 +157,8 @@ export default {
},
handleUpdateFields(field, value) {
this.fields = { ...this.fields, [field]: value };
const newErrors = validateFields(this.fields);
const newErrors = validateStage({ ...this.fields, custom: true });
newErrors.endEventIdentifier =
this.fields.startEventIdentifier && this.eventMismatchError
? [ERRORS.INVALID_EVENT_PAIRS]
......
<script>
import { GlForm, GlFormInput, GlFormGroup, GlModal } from '@gitlab/ui';
import Vue from 'vue';
import { GlButton, GlForm, GlFormInput, GlFormGroup, GlModal } from '@gitlab/ui';
import { debounce } from 'lodash';
import { mapState, mapActions } from 'vuex';
import { sprintf, __ } from '~/locale';
import { sprintf } from '~/locale';
import {
DEFAULT_STAGE_CONFIG,
STAGE_SORT_DIRECTION,
I18N,
} from './create_value_stream_form/constants';
import { validateValueStreamName, validateStage } from './create_value_stream_form/utils';
import DefaultStageFields from './create_value_stream_form/default_stage_fields.vue';
import { DATA_REFETCH_DELAY } from '../../shared/constants';
const ERRORS = {
MIN_LENGTH: __('Name is required'),
MAX_LENGTH: __('Maximum length 100 characters'),
};
const NAME_MAX_LENGTH = 100;
const validate = ({ name }) => {
const errors = { name: [] };
if (name.length > NAME_MAX_LENGTH) {
errors.name.push(ERRORS.MAX_LENGTH);
}
if (!name.length) {
errors.name.push(ERRORS.MIN_LENGTH);
}
return errors;
};
const swapArrayItems = (arr, left, right) => [
...arr.slice(0, left),
arr[right],
arr[left],
...arr.slice(right + 1, arr.length),
];
const I18N = {
CREATE_VALUE_STREAM: __('Create Value Stream'),
CREATED: __("'%{name}' Value Stream created"),
CANCEL: __('Cancel'),
MODAL_TITLE: __('Value Stream Name'),
FIELD_NAME_LABEL: __('Name'),
FIELD_NAME_PLACEHOLDER: __('Example: My Value Stream'),
};
const findStageIndexByName = (stages, target = '') =>
stages.findIndex(({ name }) => name === target);
export default {
name: 'ValueStreamForm',
components: {
GlButton,
GlForm,
GlFormInput,
GlFormGroup,
GlModal,
DefaultStageFields,
},
props: {
initialData: {
......@@ -53,10 +46,18 @@ export default {
},
},
data() {
const { hasExtendedFormFields, initialData } = this;
const additionalFields = hasExtendedFormFields
? {
stages: DEFAULT_STAGE_CONFIG,
...initialData,
}
: { stages: [] };
return {
errors: {},
name: '',
...this.initialData,
nameError: {},
stageErrors: [],
...additionalFields,
};
},
computed: {
......@@ -64,22 +65,26 @@ export default {
initialFormErrors: 'createValueStreamErrors',
isCreating: 'isCreatingValueStream',
}),
isValid() {
return !this.errors.name?.length;
...mapState('customStages', ['formEvents']),
isValueStreamNameValid() {
return !this.nameError.name?.length;
},
invalidFeedback() {
return this.errors.name?.join('\n');
return this.nameError.name?.join('\n');
},
hasFormErrors() {
hasInitialFormErrors() {
const { initialFormErrors } = this;
return Boolean(Object.keys(initialFormErrors).length);
},
isValid() {
return this.isValueStreamNameValid && !this.hasInitialFormErrors;
},
isLoading() {
return this.isCreating;
},
primaryProps() {
return {
text: this.$options.I18N.CREATE_VALUE_STREAM,
text: this.$options.I18N.FORM_TITLE,
attributes: [
{ variant: 'success' },
{ disabled: !this.isValid },
......@@ -87,37 +92,93 @@ export default {
],
};
},
hiddenStages() {
return this.stages.filter((stage) => stage.hidden);
},
activeStages() {
return this.stages.filter((stage) => !stage.hidden);
},
},
watch: {
initialFormErrors(newErrors = {}) {
this.errors = newErrors;
this.stageErrors = newErrors;
},
},
mounted() {
const { initialFormErrors } = this;
if (this.hasFormErrors) {
this.errors = initialFormErrors;
if (this.hasInitialFormErrors) {
this.stageErrors = initialFormErrors;
} else {
this.onHandleInput();
this.validate();
}
},
methods: {
...mapActions(['createValueStream']),
onHandleInput: debounce(function debouncedValidation() {
onUpdateValueStreamName: debounce(function debouncedValidation() {
const { name } = this;
this.errors = validate({ name });
this.nameError = validateValueStreamName({ name });
}, DATA_REFETCH_DELAY),
onSubmit() {
const { name } = this;
return this.createValueStream({ name }).then(() => {
if (!this.hasFormErrors) {
this.$toast.show(sprintf(this.$options.I18N.CREATED, { name }), {
const { name, stages } = this;
return this.createValueStream({
name,
stages: stages.map(({ name: stageName, ...rest }) => ({
name: stageName,
...rest,
title: stageName,
})),
}).then(() => {
if (!this.hasInitialFormErrors) {
this.$toast.show(sprintf(this.$options.I18N.FORM_CREATED, { name }), {
position: 'top-center',
});
this.name = '';
}
});
},
stageGroupLabel(index) {
return sprintf(this.$options.I18N.STAGE_INDEX, { index: index + 1 });
},
recoverStageTitle(name) {
return sprintf(this.$options.I18N.HIDDEN_DEFAULT_STAGE, { name });
},
validateStages() {
return this.activeStages.map(validateStage);
},
validate() {
const { name } = this;
this.nameError = validateValueStreamName({ name });
this.stageErrors = this.validateStages();
},
handleMove({ index, direction }) {
const newStages =
direction === STAGE_SORT_DIRECTION.UP
? swapArrayItems(this.stages, index - 1, index)
: swapArrayItems(this.stages, index, index + 1);
Vue.set(this, 'stages', newStages);
},
validateStageFields(index) {
Vue.set(this.stageErrors, index, validateStage(this.activeStages[index]));
},
fieldErrors(index) {
return this.stageErrors[index];
},
onHide(index) {
const stage = this.stages[index];
Vue.set(this.stages, index, { ...stage, hidden: true });
},
onRestore(hiddenStageIndex) {
const stage = this.hiddenStages[hiddenStageIndex];
const stageIndex = findStageIndexByName(this.stages, stage.name);
Vue.set(this.stages, stageIndex, { ...stage, hidden: false });
},
handleReset() {
this.name = '';
DEFAULT_STAGE_CONFIG.forEach((stage, index) => {
Vue.set(this.stages, index, { ...stage, hidden: false });
});
},
},
I18N,
};
......@@ -126,29 +187,68 @@ export default {
<gl-modal
data-testid="value-stream-form-modal"
modal-id="value-stream-form-modal"
:title="$options.I18N.MODAL_TITLE"
scrollable
:title="$options.I18N.FORM_TITLE"
:action-primary="primaryProps"
:action-cancel="{ text: $options.I18N.CANCEL }"
:action-cancel="{ text: $options.I18N.BTN_CANCEL }"
@primary.prevent="onSubmit"
>
<gl-form>
<gl-form-group
:label="$options.I18N.FIELD_NAME_LABEL"
label-for="create-value-stream-name"
:label="$options.I18N.FORM_FIELD_NAME_LABEL"
:invalid-feedback="invalidFeedback"
:state="isValid"
:state="isValueStreamNameValid"
>
<div class="gl-display-flex gl-justify-content-space-between">
<gl-form-input
id="create-value-stream-name"
v-model.trim="name"
name="create-value-stream-name"
:placeholder="$options.I18N.FIELD_NAME_PLACEHOLDER"
:state="isValid"
:placeholder="$options.I18N.FORM_FIELD_NAME_PLACEHOLDER"
:state="isValueStreamNameValid"
required
@input="onHandleInput"
@input="onUpdateValueStreamName"
/>
<gl-button
v-if="hiddenStages.length"
class="gl-ml-3"
variant="link"
@click="handleReset"
>{{ $options.I18N.RESTORE_DEFAULTS }}</gl-button
>
</div>
</gl-form-group>
<div v-if="hasExtendedFormFields" data-testid="extended-form-fields">
<hr />
<div v-for="(stage, activeStageIndex) in activeStages" :key="activeStageIndex">
<span
class="gl-display-flex gl-m-0 gl-vertical-align-middle gl-mr-2 gl-font-weight-bold gl-display-flex"
>{{ stageGroupLabel(activeStageIndex) }}</span
>
<default-stage-fields
:stage="stage"
:stage-events="formEvents"
:index="activeStageIndex"
:total-stages="activeStages.length"
:errors="fieldErrors(activeStageIndex)"
@move="handleMove"
@hide="onHide"
@input="validateStageFields(activeStageIndex)"
/>
</div>
<div v-if="hiddenStages.length">
<hr />
<gl-form-group v-for="(stage, hiddenStageIndex) in hiddenStages" :key="stage.id">
<span class="gl-m-0 gl-vertical-align-middle gl-mr-3 gl-font-weight-bold">{{
recoverStageTitle(stage.name)
}}</span>
<gl-button variant="link" @click="onRestore(hiddenStageIndex)">{{
$options.I18N.RESTORE_HIDDEN_STAGE
}}</gl-button>
</gl-form-group>
<div v-if="hasExtendedFormFields" data-testid="extended-form-fields"></div>
</div>
</div>
</gl-form>
</gl-modal>
</template>
......@@ -34,6 +34,13 @@ export default {
directives: {
GlModalDirective,
},
props: {
hasExtendedFormFields: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
...mapState({
isDeleting: 'isDeletingValueStream',
......@@ -116,7 +123,7 @@ export default {
<gl-button v-else v-gl-modal-directive="'value-stream-form-modal'">{{
$options.I18N.CREATE_VALUE_STREAM
}}</gl-button>
<value-stream-form />
<value-stream-form :has-extended-form-fields="hasExtendedFormFields" />
<gl-modal
data-testid="delete-value-stream-modal"
modal-id="delete-value-stream-modal"
......
......@@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import { GlFormGroup } from '@gitlab/ui';
import StageFieldActions from 'ee/analytics/cycle_analytics/components/create_value_stream_form/stage_field_actions.vue';
import DefaultStageFields from 'ee/analytics/cycle_analytics/components/create_value_stream_form/default_stage_fields.vue';
import { customStageEvents as stageEvents } from '../../mock_data';
let wrapper = null;
......@@ -9,10 +10,12 @@ const defaultStageIndex = 0;
const totalStages = 5;
const stageNameError = 'Name is required';
const defaultErrors = { name: [stageNameError] };
const ISSUE_CREATED = { id: 'issue_created', name: 'Issue created' };
const ISSUE_CLOSED = { id: 'issue_closed', name: 'Issue closed' };
const defaultStage = {
name: 'Cool new stage',
startEventIdentifier: 'some_start_event',
endEventIdentifier: 'some_end_event',
startEventIdentifier: [ISSUE_CREATED.id],
endEventIdentifier: [ISSUE_CLOSED.id],
endEventLabel: 'some_label',
};
......@@ -24,6 +27,7 @@ describe('DefaultStageFields', () => {
totalStages,
stage,
errors,
stageEvents,
},
stubs: {
'labels-selector': false,
......@@ -54,12 +58,12 @@ describe('DefaultStageFields', () => {
it('renders the field start event', () => {
expect(findStartEvent().exists()).toBe(true);
expect(findStartEvent().html()).toContain(defaultStage.startEventIdentifier);
expect(findStartEvent().html()).toContain(ISSUE_CREATED.name);
});
it('renders the field end event', () => {
const content = findEndEvent().html();
expect(content).toContain(defaultStage.endEventIdentifier);
expect(content).toContain(ISSUE_CLOSED.name);
expect(content).toContain(defaultStage.endEventLabel);
});
......
import { initializeFormData } from 'ee/analytics/cycle_analytics/components/create_value_stream_form/utils';
import {
initializeFormData,
validateStage,
validateValueStreamName,
} from 'ee/analytics/cycle_analytics/components/create_value_stream_form/utils';
import {
ERRORS,
NAME_MAX_LENGTH,
} from 'ee/analytics/cycle_analytics/components/create_value_stream_form/constants';
import { emptyErrorsState, emptyState, formInitialData } from './mock_data';
describe('initializeFormData', () => {
......@@ -71,3 +80,41 @@ describe('initializeFormData', () => {
});
});
});
const expectFieldError = ({ error, field, result }) =>
expect(result).toMatchObject({ [field]: [error] });
describe('validateStage', () => {
const defaultFields = {
name: '',
startEventIdentifier: '',
endEventIdentifier: '',
custom: true,
};
it.each`
field | value | error | msg
${'name'} | ${'a'.repeat(NAME_MAX_LENGTH + 1)} | ${ERRORS.MAX_LENGTH} | ${'is too long'}
${'name'} | ${'issue'} | ${ERRORS.STAGE_NAME_EXISTS} | ${'is a lowercase default name'}
${'name'} | ${'Issue'} | ${ERRORS.STAGE_NAME_EXISTS} | ${'is a capitalized default name'}
${'endEventIdentifier'} | ${''} | ${ERRORS.START_EVENT_REQUIRED} | ${'has no corresponding start event'}
`('returns "$error" if $field $msg', ({ field, value, error }) => {
const result = validateStage({ ...defaultFields, [field]: value });
expectFieldError({ result, error, field });
});
});
describe('validateValueStreamName,', () => {
it('with valid data returns an empty array', () => {
expect(validateValueStreamName({ name: 'Cool stream name' })).toEqual({ name: [] });
});
it.each`
name | error | msg
${'a'.repeat(NAME_MAX_LENGTH + 1)} | ${ERRORS.MAX_LENGTH} | ${'too long'}
${''} | ${ERRORS.MIN_LENGTH} | ${'too short'}
`('returns "$error" if name is $msg', ({ name, error }) => {
const result = validateValueStreamName({ name });
expectFieldError({ result, error, field: 'name' });
});
});
import { GlModal, GlFormGroup } from '@gitlab/ui';
import { GlModal } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import ValueStreamForm from 'ee/analytics/cycle_analytics/components/value_stream_form.vue';
import { customStageEvents as formEvents } from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
......@@ -25,6 +26,14 @@ describe('ValueStreamForm', () => {
actions: {
createValueStream: createValueStreamMock,
},
modules: {
customStages: {
namespaced: true,
state: {
formEvents,
},
},
},
});
const createComponent = ({ props = {}, data = {}, initialState = {} } = {}) =>
......@@ -50,7 +59,6 @@ describe('ValueStreamForm', () => {
const createSubmitButtonDisabledState = () =>
findModal().props('actionPrimary').attributes[1].disabled;
const submitModal = () => findModal().vm.$emit('primary', mockEvent);
const findFormGroup = () => wrapper.find(GlFormGroup);
const findExtendedFormFields = () => wrapper.find('[data-testid="extended-form-fields"]');
afterEach(() => {
......@@ -92,12 +100,6 @@ describe('ValueStreamForm', () => {
});
});
it('renders the error', () => {
expect(findFormGroup().attributes('invalid-feedback')).toEqual(
createValueStreamErrors.name.join('\n'),
);
});
it('submit button is disabled', () => {
expect(createSubmitButtonDisabledState()).toBe(true);
});
......@@ -120,6 +122,7 @@ describe('ValueStreamForm', () => {
it('calls the "createValueStream" event when submitted', () => {
expect(createValueStreamMock).toHaveBeenCalledWith(expect.any(Object), {
name: streamName,
stages: [],
});
});
......
......@@ -8249,6 +8249,9 @@ msgstr ""
msgid "CreateValueStreamForm|%{name} (default)"
msgstr ""
msgid "CreateValueStreamForm|'%{name}' Value Stream created"
msgstr ""
msgid "CreateValueStreamForm|Add stage"
msgstr ""
......@@ -8294,6 +8297,9 @@ msgstr ""
msgid "CreateValueStreamForm|Recover hidden stage"
msgstr ""
msgid "CreateValueStreamForm|Restore defaults"
msgstr ""
msgid "CreateValueStreamForm|Restore stage"
msgstr ""
......@@ -11553,9 +11559,6 @@ msgstr ""
msgid "Example: @sub\\.company\\.com$"
msgstr ""
msgid "Example: My Value Stream"
msgstr ""
msgid "Example: Usage = single query. (Requested) / (Capacity) = multiple queries combined into a formula."
msgstr ""
......@@ -17223,9 +17226,6 @@ msgstr ""
msgid "Maximum job timeout has a value which could not be accepted"
msgstr ""
msgid "Maximum length 100 characters"
msgstr ""
msgid "Maximum lifetime allowable for Personal Access Tokens is active, your expire date must be set before %{maximum_allowable_date}."
msgstr ""
......@@ -18457,9 +18457,6 @@ msgstr ""
msgid "Name has already been taken"
msgstr ""
msgid "Name is required"
msgstr ""
msgid "Name new label"
msgstr ""
......@@ -30893,9 +30890,6 @@ msgstr ""
msgid "Value Stream Analytics gives an overview of how much time it takes to go from idea to production in your project."
msgstr ""
msgid "Value Stream Name"
msgstr ""
msgid "ValueStreamAnalyticsStage|We don't have enough data to show this stage."
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment