Commit 6f1ec0d1 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo Committed by Olena Horal-Koretska

Ensure we can prepopulate the edit form

Ensure we remove the modal when not in use

Ensures we display the start and end events
for default stages when creating initial data
parent 91817c59
......@@ -2,9 +2,12 @@ import { __, s__, sprintf } from '~/locale';
export const NAME_MAX_LENGTH = 100;
export const I18N = {
FORM_TITLE: __('Create Value Stream'),
export const i18n = {
FORM_TITLE: s__('CreateValueStreamForm|Create Value Stream'),
EDIT_FORM_TITLE: s__('CreateValueStreamForm|Edit Value Stream'),
EDIT_FORM_ACTION: s__('CreateValueStreamForm|Save Value Stream'),
FORM_CREATED: s__("CreateValueStreamForm|'%{name}' Value Stream created"),
FORM_EDITED: s__("CreateValueStreamForm|'%{name}' Value Stream edited"),
RECOVER_HIDDEN_STAGE: s__('CreateValueStreamForm|Recover hidden stage'),
RESTORE_HIDDEN_STAGE: s__('CreateValueStreamForm|Restore stage'),
RESTORE_DEFAULTS: s__('CreateValueStreamForm|Restore defaults'),
......@@ -55,23 +58,17 @@ export const STAGE_SORT_DIRECTION = {
DOWN: 'DOWN',
};
export const defaultErrors = {
id: [],
name: [],
startEventIdentifier: [],
startEventLabelId: [],
endEventIdentifier: [],
endEventLabelId: [],
};
export const formFieldKeys = [
'id',
'name',
'startEventIdentifier',
'endEventIdentifier',
'startEventLabelId',
'endEventLabelId',
];
export const defaultFields = {
id: null,
name: null,
startEventIdentifier: null,
startEventLabelId: null,
endEventIdentifier: null,
endEventLabelId: null,
};
export const defaultFields = formFieldKeys.reduce((acc, field) => ({ ...acc, [field]: null }), {});
export const defaultErrors = formFieldKeys.reduce((acc, field) => ({ ...acc, [field]: [] }), {});
export const defaultCustomStageFields = { ...defaultFields, custom: true };
......@@ -79,11 +76,11 @@ export const PRESET_OPTIONS_DEFAULT = 'default';
export const PRESET_OPTIONS_BLANK = 'blank';
export const PRESET_OPTIONS = [
{
text: I18N.TEMPLATE_DEFAULT,
text: i18n.TEMPLATE_DEFAULT,
value: PRESET_OPTIONS_DEFAULT,
},
{
text: I18N.TEMPLATE_BLANK,
text: i18n.TEMPLATE_BLANK,
value: PRESET_OPTIONS_BLANK,
},
];
......@@ -95,14 +92,14 @@ export const PRESET_OPTIONS = [
export const ADDITIONAL_DEFAULT_STAGE_EVENTS = [
{
identifier: 'issue_stage_end',
name: I18N.ISSUE_STAGE_END,
name: i18n.ISSUE_STAGE_END,
},
{
identifier: 'plan_stage_start',
name: I18N.PLAN_STAGE_START,
name: i18n.PLAN_STAGE_START,
},
{
identifier: 'code_stage_start',
name: I18N.CODE_STAGE_START,
name: i18n.CODE_STAGE_START,
},
];
......@@ -2,7 +2,7 @@
import { GlFormGroup, GlFormInput, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { isLabelEvent, getLabelEventsIdentifiers } from '../../utils';
import LabelsSelector from '../labels_selector.vue';
import { I18N } from './constants';
import { i18n } from './constants';
import StageFieldActions from './stage_field_actions.vue';
import { startEventOptions, endEventOptions } from './utils';
......@@ -82,10 +82,10 @@ export default {
return ev?.name || null;
},
eventName(eventId, textKey) {
return eventId ? this.eventNameByIdentifier(eventId) : this.$options.I18N[textKey];
return eventId ? this.eventNameByIdentifier(eventId) : this.$options.i18n[textKey];
},
},
I18N,
i18n,
};
</script>
<template>
......@@ -101,7 +101,7 @@ export default {
<gl-form-input
v-model.trim="stage.name"
:name="`custom-stage-name-${index}`"
:placeholder="$options.I18N.FORM_FIELD_STAGE_NAME_PLACEHOLDER"
:placeholder="$options.i18n.FORM_FIELD_STAGE_NAME_PLACEHOLDER"
required
@input="$emit('input', { field: 'name', value: $event })"
/>
......@@ -120,7 +120,7 @@ export default {
<gl-form-group
:data-testid="`custom-stage-start-event-${index}`"
class="gl-w-half gl-mr-2"
:label="$options.I18N.FORM_FIELD_START_EVENT"
:label="$options.i18n.FORM_FIELD_START_EVENT"
:state="hasFieldErrors('startEventIdentifier')"
:invalid-feedback="fieldErrorMessage('startEventIdentifier')"
>
......@@ -144,7 +144,7 @@ export default {
v-if="startEventRequiresLabel"
class="gl-w-half gl-ml-2"
:data-testid="`custom-stage-start-event-label-${index}`"
:label="$options.I18N.FORM_FIELD_START_EVENT_LABEL"
:label="$options.i18n.FORM_FIELD_START_EVENT_LABEL"
:state="hasFieldErrors('startEventLabelId')"
:invalid-feedback="fieldErrorMessage('startEventLabelId')"
>
......@@ -159,7 +159,7 @@ export default {
<gl-form-group
:data-testid="`custom-stage-end-event-${index}`"
class="gl-w-half gl-mr-2"
:label="$options.I18N.FORM_FIELD_END_EVENT"
:label="$options.i18n.FORM_FIELD_END_EVENT"
:state="hasFieldErrors('endEventIdentifier')"
:invalid-feedback="fieldErrorMessage('endEventIdentifier')"
>
......@@ -184,7 +184,7 @@ export default {
v-if="endEventRequiresLabel"
class="gl-w-half gl-ml-2"
:data-testid="`custom-stage-end-event-label-${index}`"
:label="$options.I18N.FORM_FIELD_END_EVENT_LABEL"
:label="$options.i18n.FORM_FIELD_END_EVENT_LABEL"
:state="hasFieldErrors('endEventLabelId')"
:invalid-feedback="fieldErrorMessage('endEventLabelId')"
>
......
<script>
import { GlFormGroup, GlFormInput, GlFormText } from '@gitlab/ui';
import { I18N, ADDITIONAL_DEFAULT_STAGE_EVENTS } from './constants';
import { i18n, ADDITIONAL_DEFAULT_STAGE_EVENTS } from './constants';
import StageFieldActions from './stage_field_actions.vue';
const findStageEvent = (stageEvents = [], eid = null) => {
......@@ -55,7 +55,7 @@ export default {
return eventIdToName([...this.stageEvents, ...ADDITIONAL_DEFAULT_STAGE_EVENTS], eventId);
},
},
I18N,
i18n,
};
</script>
<template>
......@@ -71,7 +71,7 @@ export default {
<gl-form-input
v-model.trim="stage.name"
:name="`create-value-stream-stage-${index}`"
:placeholder="$options.I18N.FORM_FIELD_STAGE_NAME_PLACEHOLDER"
:placeholder="$options.i18n.FORM_FIELD_STAGE_NAME_PLACEHOLDER"
required
@input="$emit('input', $event)"
/>
......@@ -86,7 +86,7 @@ export default {
</div>
<div class="gl-display-flex gl-align-items-center" :data-testid="`stage-start-event-${index}`">
<span class="gl-m-0 gl-vertical-align-middle gl-mr-2 gl-font-weight-bold">{{
$options.I18N.DEFAULT_FIELD_START_EVENT_LABEL
$options.i18n.DEFAULT_FIELD_START_EVENT_LABEL
}}</span>
<gl-form-text class="gl-m-0">{{ eventName(stage.startEventIdentifier) }}</gl-form-text>
<gl-form-text v-if="stage.startEventLabel" class="gl-m-0"
......@@ -95,7 +95,7 @@ export default {
</div>
<div class="gl-display-flex gl-align-items-center" :data-testid="`stage-end-event-${index}`">
<span class="gl-m-0 gl-vertical-align-middle gl-mr-2 gl-font-weight-bold">{{
$options.I18N.DEFAULT_FIELD_END_EVENT_LABEL
$options.i18n.DEFAULT_FIELD_END_EVENT_LABEL
}}</span>
<gl-form-text class="gl-m-0">{{ eventName(stage.endEventIdentifier) }}</gl-form-text>
<gl-form-text v-if="stage.endEventLabel" class="gl-m-0"
......
import { isEqual, pick } from 'lodash';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import { DEFAULT_STAGE_NAMES } from '../../constants';
import { isStartEvent, getAllowedEndEvents, eventToOption, eventsByIdentifier } from '../../utils';
import { I18N, ERRORS, defaultErrors, defaultFields, NAME_MAX_LENGTH } from './constants';
import {
i18n,
ERRORS,
defaultErrors,
defaultFields,
NAME_MAX_LENGTH,
formFieldKeys,
} from './constants';
/**
* @typedef {Object} CustomStageEvents
......@@ -22,7 +31,7 @@ import { I18N, ERRORS, defaultErrors, defaultFields, NAME_MAX_LENGTH } from './c
* @returns {DropdownData[]} array of start events formatted for dropdowns
*/
export const startEventOptions = (eventsList) => [
{ value: null, text: I18N.SELECT_START_EVENT },
{ value: null, text: i18n.SELECT_START_EVENT },
...eventsList.filter(isStartEvent).map(eventToOption),
];
......@@ -37,7 +46,7 @@ export const startEventOptions = (eventsList) => [
export const endEventOptions = (eventsList, startEventIdentifier) => {
const endEvents = getAllowedEndEvents(eventsList, startEventIdentifier);
return [
{ value: null, text: I18N.SELECT_END_EVENT },
{ value: null, text: i18n.SELECT_END_EVENT },
...eventsByIdentifier(eventsList, endEvents).map(eventToOption),
];
};
......@@ -123,3 +132,111 @@ export const validateValueStreamName = ({ name = '' }) => {
}
return errors;
};
/**
* Formats the value stream stages for submission, ensures that the
* 'custom' property is set when we are editing, we include the `id` if its
* set and all fields are converted to snake case
*
* @param {Array} stages array of value stream stages
* @param {Boolean} isEditing flag to indicate if we are editing a value stream or creating
* @returns {Array} the array prepared to be submitted for persistence
*/
export const formatStageDataForSubmission = (stages, isEditing = false) => {
return stages.map(({ id = null, custom = false, name, ...rest }) => {
let editProps = { custom };
if (isEditing) {
// We can add a new stage to the value stream when either creating, or editing
// If a new stage has been added then at this point, the `id` won't exist
// The new stage is still `custom` but wont have an id until the form submits and its persisted to the DB
editProps = id ? { id, custom: true } : { custom: true };
}
return custom
? convertObjectPropsToSnakeCase({ ...rest, ...editProps, name })
: convertObjectPropsToSnakeCase({ ...editProps, name });
});
};
/**
* Checks an array of value stream stages to see if there are
* any differences in the values they contain
*
* @param {Array} stages array of value stream stages
* @param {Array} stages array of value stream stages
* @returns {Boolean} returns true if there is a difference in the 2 arrays
*/
export const hasDirtyStage = (currentStages, originalStages) => {
const cs = currentStages.map((s) => pick(s, formFieldKeys));
const os = originalStages.map((s) => pick(s, formFieldKeys));
return !isEqual(cs, os);
};
/**
* Checks if the target name matches the name of any of the value
* stream stages passed in
*
* @param {Array} stages array of value stream stages
* @param {String} targetName name we are searching for
* @returns {Object} returns the found object or null
*/
const findStageByName = (stages, targetName = '') =>
stages.find(({ name }) => name.toLowerCase().trim() === targetName.toLowerCase().trim());
/**
* Returns a valid custom value stream stage
*
* @param {Object} stage a raw value stream stage retrieved from the vuex store
* @returns {Object} the same stage with fields adjusted for the value stream form
*/
const prepareCustomStage = ({ startEventLabel = {}, endEventLabel = {}, ...rest }) => ({
...rest,
startEventLabelId: startEventLabel?.id || null,
endEventLabelId: endEventLabel?.id || null,
isDefault: false,
});
/**
* Returns a valid default value stream stage
*
* @param {Object} stage a raw value stream stage retrieved from the vuex store
* @returns {Object} the same stage with fields adjusted for the value stream form
*/
const prepareDefaultStage = (defaultStageConfig, { name, ...rest }) => {
// default stages currently dont have any label based events
const stage = findStageByName(defaultStageConfig, name) || null;
if (!stage) return {};
const { startEventIdentifier = null, endEventIdentifier = null } = stage;
return {
...rest,
name,
startEventIdentifier,
endEventIdentifier,
isDefault: true,
};
};
/**
* Returns a valid array of value stream stages for
* use in the value stream form
*
* @param {Array} stage an array of raw value stream stages retrieved from the vuex store
* @param {Array} stage an array of raw value stream stages retrieved from the vuex store
* @returns {Object} the same stage with fields adjusted for the value stream form
*/
export const generateInitialStageData = (defaultStageConfig, selectedValueStreamStages) =>
selectedValueStreamStages.map(
({ startEventIdentifier = null, endEventIdentifier = null, custom = false, ...rest }) => {
const stageData =
custom && startEventIdentifier && endEventIdentifier
? prepareCustomStage({ ...rest, startEventIdentifier, endEventIdentifier })
: prepareDefaultStage(defaultStageConfig, rest);
if (stageData?.name) {
return {
...stageData,
custom,
};
}
return {};
},
);
......@@ -13,7 +13,7 @@ import { mapGetters, mapState } from 'vuex';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import { STAGE_ACTIONS } from '../constants';
import { getAllowedEndEvents, getLabelEventsIdentifiers, isLabelEvent } from '../utils';
import { defaultFields, ERRORS, I18N } from './create_value_stream_form/constants';
import { defaultFields, ERRORS, i18n } from './create_value_stream_form/constants';
import CustomStageFormFields from './create_value_stream_form/custom_stage_fields.vue';
import { validateStage, initializeFormData } from './create_value_stream_form/utils';
......@@ -99,10 +99,10 @@ export default {
return !endEvents.length || !endEvents.includes(endEventIdentifier);
},
saveStageText() {
return this.isEditingCustomStage ? I18N.BTN_UPDATE_STAGE : I18N.BTN_ADD_STAGE;
return this.isEditingCustomStage ? i18n.BTN_UPDATE_STAGE : i18n.BTN_ADD_STAGE;
},
formTitle() {
return this.isEditingCustomStage ? I18N.TITLE_EDIT_STAGE : I18N.TITLE_ADD_STAGE;
return this.isEditingCustomStage ? i18n.TITLE_EDIT_STAGE : i18n.TITLE_ADD_STAGE;
},
hasHiddenStages() {
return this.hiddenStages.length;
......@@ -167,7 +167,7 @@ export default {
Vue.set(this, 'errors', newErrors);
},
},
I18N,
i18n,
};
</script>
<template>
......@@ -178,12 +178,12 @@ export default {
<div class="gl-mb-1 gl-display-flex gl-justify-content-space-between gl-align-items-center">
<h4>{{ formTitle }}</h4>
<gl-dropdown
:text="$options.I18N.RECOVER_HIDDEN_STAGE"
:text="$options.i18n.RECOVER_HIDDEN_STAGE"
data-testid="recover-hidden-stage-dropdown"
right
>
<gl-dropdown-section-header>{{
$options.I18N.RECOVER_STAGE_TITLE
$options.i18n.RECOVER_STAGE_TITLE
}}</gl-dropdown-section-header>
<template v-if="hasHiddenStages">
<gl-dropdown-item
......@@ -193,7 +193,7 @@ export default {
>{{ stage.title }}</gl-dropdown-item
>
</template>
<p v-else class="gl-mx-5 gl-my-3">{{ $options.I18N.RECOVER_STAGES_VISIBLE }}</p>
<p v-else class="gl-mx-5 gl-my-3">{{ $options.i18n.RECOVER_STAGES_VISIBLE }}</p>
</gl-dropdown>
</div>
<custom-stage-form-fields
......@@ -212,7 +212,7 @@ export default {
data-testid="cancel-custom-stage"
@click="handleCancel"
>
{{ $options.I18N.BTN_CANCEL }}
{{ $options.i18n.BTN_CANCEL }}
</gl-button>
<gl-button
:disabled="!isComplete || !isDirty"
......
<script>
import { GlButton, GlForm, GlFormInput, GlFormGroup, GlFormRadioGroup, GlModal } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import Vue from 'vue';
import { mapState, mapActions } from 'vuex';
import { swapArrayItems } from '~/lib/utils/array_utility';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import { sprintf } from '~/locale';
import {
STAGE_SORT_DIRECTION,
I18N,
i18n,
defaultCustomStageFields,
PRESET_OPTIONS,
PRESET_OPTIONS_DEFAULT,
} from './create_value_stream_form/constants';
import CustomStageFields from './create_value_stream_form/custom_stage_fields.vue';
import DefaultStageFields from './create_value_stream_form/default_stage_fields.vue';
import { validateValueStreamName, validateStage } from './create_value_stream_form/utils';
import {
validateValueStreamName,
validateStage,
formatStageDataForSubmission,
hasDirtyStage,
} from './create_value_stream_form/utils';
const initializeStageErrors = (defaultStageConfig, selectedPreset = PRESET_OPTIONS_DEFAULT) =>
selectedPreset === PRESET_OPTIONS_DEFAULT ? defaultStageConfig.map(() => ({})) : [{}];
......@@ -24,16 +29,6 @@ const initializeStages = (defaultStageConfig, selectedPreset = PRESET_OPTIONS_DE
? defaultStageConfig
: [{ ...defaultCustomStageFields }];
const formatStageDataForSubmission = (stages) => {
return stages.map(({ custom = false, name, ...rest }) => {
return custom
? convertObjectPropsToSnakeCase({ ...rest, custom, name })
: {
name,
};
});
};
export default {
name: 'ValueStreamForm',
components: {
......@@ -71,21 +66,28 @@ export default {
type: Array,
required: true,
},
isEditing: {
type: Boolean,
required: false,
default: false,
},
},
data() {
const {
defaultStageConfig = [],
hasExtendedFormFields,
initialData,
initialData: { name: initialName, stages: initialStages },
initialFormErrors,
initialPreset,
} = this;
const { name: nameError = [], stages: stageErrors = [{}] } = initialFormErrors;
const additionalFields = hasExtendedFormFields
? {
stages: initializeStages(defaultStageConfig, initialPreset),
stageErrors: stageErrors || initializeStageErrors(defaultStageConfig, initialPreset),
...initialData,
stages: this.isEditing
? cloneDeep(initialStages)
: initializeStages(defaultStageConfig, initialPreset),
stageErrors:
cloneDeep(stageErrors) || initializeStageErrors(defaultStageConfig, initialPreset),
}
: { stages: [], nameError };
......@@ -93,7 +95,7 @@ export default {
hiddenStages: [],
selectedPreset: initialPreset,
presetOptions: PRESET_OPTIONS,
name: '',
name: initialName,
nameError,
stageErrors,
...additionalFields,
......@@ -115,15 +117,18 @@ export default {
isLoading() {
return this.isCreating;
},
formTitle() {
return this.isEditing ? this.$options.i18n.EDIT_FORM_TITLE : this.$options.i18n.FORM_TITLE;
},
primaryProps() {
return {
text: this.$options.I18N.FORM_TITLE,
text: this.isEditing ? this.$options.i18n.EDIT_FORM_ACTION : this.$options.i18n.FORM_TITLE,
attributes: [{ variant: 'success' }, { loading: this.isLoading }],
};
},
secondaryProps() {
return {
text: this.$options.I18N.BTN_ADD_ANOTHER_STAGE,
text: this.$options.i18n.BTN_ADD_ANOTHER_STAGE,
attributes: [
{ category: 'secondary' },
{ variant: 'info' },
......@@ -136,24 +141,42 @@ export default {
this.nameError.length || this.stageErrors.some((obj) => Object.keys(obj).length),
);
},
isDirtyEditing() {
return (
this.isEditing &&
(this.hasDirtyName(this.name, this.initialData.name) ||
hasDirtyStage(this.stages, this.initialData.stages))
);
},
watch: {
initialFormErrors({ name: nameError, stages: stageErrors }) {
Vue.set(this, 'nameError', nameError);
Vue.set(this, 'stageErrors', stageErrors);
canRestore() {
return this.hiddenStages.length || this.isDirtyEditing;
},
},
methods: {
...mapActions(['createValueStream']),
...mapActions(['createValueStream', 'updateValueStream']),
onSubmit() {
this.validate();
if (this.hasFormErrors) return false;
return this.createValueStream({
let req = this.createValueStream;
let params = {
name: this.name,
stages: formatStageDataForSubmission(this.stages),
}).then(() => {
stages: formatStageDataForSubmission(this.stages, this.isEditing),
};
if (this.isEditing) {
req = this.updateValueStream;
params = {
...params,
id: this.initialData.id,
};
}
return req(params).then(() => {
if (!this.hasInitialFormErrors) {
this.$toast.show(sprintf(this.$options.I18N.FORM_CREATED, { name: this.name }), {
const msg = this.isEditing
? this.$options.i18n.FORM_EDITED
: this.$options.i18n.FORM_CREATED;
this.$toast.show(sprintf(msg, { name: this.name }), {
position: 'top-center',
});
this.name = '';
......@@ -169,10 +192,13 @@ export default {
: `custom-template-stage-${index}`;
},
stageGroupLabel(index) {
return sprintf(this.$options.I18N.STAGE_INDEX, { index: index + 1 });
return sprintf(this.$options.i18n.STAGE_INDEX, { index: index + 1 });
},
recoverStageTitle(name) {
return sprintf(this.$options.I18N.HIDDEN_DEFAULT_STAGE, { name });
return sprintf(this.$options.i18n.HIDDEN_DEFAULT_STAGE, { name });
},
hasDirtyName(current, original) {
return current.trim().toLowerCase() !== original.trim().toLowerCase();
},
validateStages() {
return this.stages.map(validateStage);
......@@ -190,8 +216,8 @@ export default {
handleMove({ index, direction }) {
const newStages = this.moveItem(this.stages, index, direction);
const newErrors = this.moveItem(this.stageErrors, index, direction);
Vue.set(this, 'stageErrors', newErrors);
Vue.set(this, 'stages', newStages);
Vue.set(this, 'stageErrors', cloneDeep(newErrors));
Vue.set(this, 'stages', cloneDeep(newStages));
},
validateStageFields(index) {
Vue.set(this.stageErrors, index, validateStage(this.stages[index]));
......@@ -228,10 +254,20 @@ export default {
Vue.set(this.stages, activeStageIndex, updatedStage);
},
handleResetDefaults() {
if (this.isEditing) {
const {
initialData: { name: initialName, stages: initialStages },
} = this;
Vue.set(this, 'name', initialName);
Vue.set(this, 'nameError', []);
Vue.set(this, 'stages', cloneDeep(initialStages));
Vue.set(this, 'stageErrors', [{}]);
} else {
this.name = '';
this.defaultStageConfig.forEach((stage, index) => {
Vue.set(this.stages, index, { ...stage, hidden: false });
});
}
},
handleResetBlank() {
this.name = '';
......@@ -250,7 +286,7 @@ export default {
);
},
},
I18N,
i18n,
};
</script>
<template>
......@@ -259,10 +295,11 @@ export default {
modal-id="value-stream-form-modal"
dialog-class="gl-align-items-flex-start! gl-py-7"
scrollable
:title="$options.I18N.FORM_TITLE"
:title="formTitle"
:action-primary="primaryProps"
:action-secondary="secondaryProps"
:action-cancel="{ text: $options.I18N.BTN_CANCEL }"
:action-cancel="{ text: $options.i18n.BTN_CANCEL }"
@hidden.prevent="$emit('hidden')"
@secondary.prevent="onAddStage"
@primary.prevent="onSubmit"
>
......@@ -270,7 +307,7 @@ export default {
<gl-form-group
data-testid="create-value-stream-name"
label-for="create-value-stream-name"
:label="$options.I18N.FORM_FIELD_NAME_LABEL"
:label="$options.i18n.FORM_FIELD_NAME_LABEL"
:invalid-feedback="invalidNameFeedback"
:state="isValueStreamNameValid"
>
......@@ -279,21 +316,21 @@ export default {
id="create-value-stream-name"
v-model.trim="name"
name="create-value-stream-name"
:placeholder="$options.I18N.FORM_FIELD_NAME_PLACEHOLDER"
:placeholder="$options.i18n.FORM_FIELD_NAME_PLACEHOLDER"
:state="isValueStreamNameValid"
required
/>
<gl-button
v-if="hiddenStages.length"
v-if="canRestore"
class="gl-ml-3"
variant="link"
@click="handleResetDefaults"
>{{ $options.I18N.RESTORE_DEFAULTS }}</gl-button
>{{ $options.i18n.RESTORE_DEFAULTS }}</gl-button
>
</div>
</gl-form-group>
<gl-form-radio-group
v-if="hasExtendedFormFields"
v-if="hasExtendedFormFields && !isEditing"
v-model="selectedPreset"
class="gl-mb-4"
data-testid="vsa-preset-selector"
......@@ -338,7 +375,7 @@ export default {
recoverStageTitle(stage.name)
}}</span>
<gl-button variant="link" @click="onRestore(hiddenStageIndex)">{{
$options.I18N.RESTORE_HIDDEN_STAGE
$options.i18n.RESTORE_HIDDEN_STAGE
}}</gl-button>
</gl-form-group>
</div>
......
......@@ -9,16 +9,20 @@ import {
GlModalDirective,
} from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { sprintf, __ } from '~/locale';
import { sprintf, __, s__ } from '~/locale';
import { generateInitialStageData } from './create_value_stream_form/utils';
import ValueStreamForm from './value_stream_form.vue';
const I18N = {
DELETE_NAME: __('Delete %{name}'),
DELETE_CONFIRMATION: __('Are you sure you want to delete "%{name}" Value Stream?'),
DELETED: __("'%{name}' Value Stream deleted"),
const i18n = {
DELETE_NAME: s__('DeleteValueStream|Delete %{name}'),
DELETE_CONFIRMATION: s__(
'DeleteValueStream|Are you sure you want to delete "%{name}" Value Stream?',
),
DELETED: s__("DeleteValueStream|'%{name}' Value Stream deleted"),
DELETE: __('Delete'),
CREATE_VALUE_STREAM: __('Create new Value Stream'),
CREATE_VALUE_STREAM: s__('CreateValueStreamForm|Create new Value Stream'),
CANCEL: __('Cancel'),
EDIT_VALUE_STREAM: __('Edit'),
};
export default {
......@@ -41,12 +45,23 @@ export default {
default: false,
},
},
data() {
return {
showCreateModal: false,
isEditing: false,
initialData: {
name: '',
stages: [],
},
};
},
computed: {
...mapState({
isDeleting: 'isDeletingValueStream',
deleteValueStreamError: 'deleteValueStreamError',
data: 'valueStreams',
selectedValueStream: 'selectedValueStream',
selectedValueStreamStages: 'stages',
initialFormErrors: 'createValueStreamErrors',
defaultStageConfig: 'defaultStageConfig',
}),
......@@ -59,14 +74,14 @@ export default {
selectedValueStreamId() {
return this.selectedValueStream?.id || null;
},
canDeleteSelectedStage() {
isCustomValueStream() {
return this.selectedValueStream?.isCustom || false;
},
deleteSelectedText() {
return sprintf(this.$options.I18N.DELETE_NAME, { name: this.selectedValueStreamName });
return sprintf(this.$options.i18n.DELETE_NAME, { name: this.selectedValueStreamName });
},
deleteConfirmationText() {
return sprintf(this.$options.I18N.DELETE_CONFIRMATION, {
return sprintf(this.$options.i18n.DELETE_CONFIRMATION, {
name: this.selectedValueStreamName,
});
},
......@@ -86,16 +101,39 @@ export default {
const name = this.selectedValueStreamName;
return this.deleteValueStream(this.selectedValueStreamId).then(() => {
if (!this.deleteValueStreamError) {
this.onSuccess(sprintf(this.$options.I18N.DELETED, { name }));
this.onSuccess(sprintf(this.$options.i18n.DELETED, { name }));
}
});
},
onCreate() {
this.showCreateModal = true;
this.isEditing = false;
this.initialData = {
name: '',
stages: [],
};
},
onEdit() {
this.showCreateModal = true;
this.isEditing = true;
this.initialData = {
...this.selectedValueStream,
stages: generateInitialStageData(this.defaultStageConfig, this.selectedValueStreamStages),
};
},
},
I18N,
i18n,
};
</script>
<template>
<div>
<gl-button
v-if="isCustomValueStream"
v-gl-modal-directive="'value-stream-form-modal'"
data-testid="edit-value-stream"
@click="onEdit"
>{{ $options.i18n.EDIT_VALUE_STREAM }}</gl-button
>
<gl-dropdown
v-if="hasValueStreams"
data-testid="dropdown-value-streams"
......@@ -111,34 +149,45 @@ export default {
>{{ streamName }}</gl-dropdown-item
>
<gl-dropdown-divider />
<gl-dropdown-item v-gl-modal-directive="'value-stream-form-modal'">{{
$options.I18N.CREATE_VALUE_STREAM
}}</gl-dropdown-item>
<gl-dropdown-item
v-if="canDeleteSelectedStage"
v-gl-modal-directive="'value-stream-form-modal'"
data-testid="create-value-stream"
@click="onCreate"
>{{ $options.i18n.CREATE_VALUE_STREAM }}</gl-dropdown-item
>
<gl-dropdown-item
v-if="isCustomValueStream"
v-gl-modal-directive="'delete-value-stream-modal'"
variant="danger"
data-testid="delete-value-stream"
>{{ deleteSelectedText }}</gl-dropdown-item
>
</gl-dropdown>
<gl-button v-else v-gl-modal-directive="'value-stream-form-modal'">{{
$options.I18N.CREATE_VALUE_STREAM
}}</gl-button>
<gl-button
v-else
v-gl-modal-directive="'value-stream-form-modal'"
data-testid="create-value-stream-button"
@click="onCreate"
>{{ $options.i18n.CREATE_VALUE_STREAM }}</gl-button
>
<value-stream-form
v-if="showCreateModal"
:initial-data="initialData"
:initial-form-errors="initialFormErrors"
:has-extended-form-fields="hasExtendedFormFields"
:default-stage-config="defaultStageConfig"
:is-editing="isEditing"
@hidden="showCreateModal = false"
/>
<gl-modal
data-testid="delete-value-stream-modal"
modal-id="delete-value-stream-modal"
:title="__('Delete Value Stream')"
:action-primary="{
text: $options.I18N.DELETE,
text: $options.i18n.DELETE,
attributes: [{ variant: 'danger' }, { loading: isDeleting }],
}"
:action-cancel="{ text: $options.I18N.CANCEL }"
:action-cancel="{ text: $options.i18n.CANCEL }"
@primary.prevent="onDelete"
>
<gl-alert v-if="deleteValueStreamError" variant="danger">{{
......
......@@ -6,6 +6,9 @@ import {
initializeFormData,
validateStage,
validateValueStreamName,
hasDirtyStage,
formatStageDataForSubmission,
generateInitialStageData,
} from 'ee/analytics/cycle_analytics/components/create_value_stream_form/utils';
import { emptyErrorsState, emptyState, formInitialData } from './mock_data';
......@@ -109,7 +112,7 @@ describe('validateStage', () => {
});
});
describe('validateValueStreamName,', () => {
describe('validateValueStreamName', () => {
it('with valid data returns an empty array', () => {
expect(validateValueStreamName({ name: 'Cool stream name' })).toEqual([]);
});
......@@ -123,3 +126,176 @@ describe('validateValueStreamName,', () => {
expect(result).toEqual([error]);
});
});
describe('hasDirtyStage', () => {
const fakeStages = [
{ id: 10, name: 'Fake new stage', startEventIdentifier: 'issue_created' },
{ id: null, name: 'Fake new stage 2' },
];
it('will return false if all required fields are equal for all stages', () => {
expect(hasDirtyStage(fakeStages, fakeStages)).toBe(false);
});
it('will return true if any stage field value is different', () => {
expect(hasDirtyStage(fakeStages, [{}, ...fakeStages])).toBe(true);
expect(hasDirtyStage(fakeStages, [fakeStages, {}])).toBe(true);
expect(
hasDirtyStage(fakeStages, [
fakeStages[0],
{ ...fakeStages[1], endEventIdentifier: 'issue_closed' },
]),
).toBe(true);
});
it('will ignore fields that are not required for the form', () => {
expect(
hasDirtyStage(fakeStages, [fakeStages[0], { ...fakeStages[1], fakeField: 'issue_closed' }]),
).toBe(false);
});
});
describe('formatStageDataForSubmission', () => {
let res = {};
const fakeStage = {
id: null,
name: 'Fake new stage',
startEventIdentifier: 'issue_created',
endEventIdentifier: 'issue_closed',
startEventLabelId: 'label A',
endEventLabelId: 'label B',
};
describe('default stages', () => {
beforeEach(() => {
[res] = formatStageDataForSubmission([fakeStage]);
});
it('will not include the `id`', () => {
expect(Object.keys(res).includes('id')).toBe(false);
});
it('will not include the event fields', () => {
[
'start_event_identifier',
'start_event_label_id',
'end_event_identifier',
'end_event_label_id',
].forEach((field) => {
expect(Object.keys(res).includes(field)).toBe(false);
});
});
it('will convert all properties to snake case', () => {
expect(Object.keys(res)).toEqual(['custom', 'name']);
});
});
describe('with a custom stage', () => {
beforeEach(() => {
[res] = formatStageDataForSubmission([{ ...fakeStage, custom: true }]);
});
it('will convert all properties to snake case', () => {
expect(Object.keys(res)).toEqual([
'start_event_identifier',
'end_event_identifier',
'start_event_label_id',
'end_event_label_id',
'custom',
'name',
]);
});
it('will include the event fields', () => {
[
'start_event_identifier',
'start_event_label_id',
'end_event_identifier',
'end_event_label_id',
].forEach((field) => {
expect(Object.keys(res).includes(field)).toBe(true);
});
});
});
describe('isEditing = true ', () => {
it('will include the `id` if it has a value', () => {
[res] = formatStageDataForSubmission([{ ...fakeStage, id: 10 }], true);
expect(Object.keys(res).includes('id')).toBe(true);
});
it('will set custom to `true`', () => {
[res] = formatStageDataForSubmission([fakeStage], true);
expect(res.custom).toBe(true);
});
});
});
describe('generateInitialStageData', () => {
const defaultConfig = {
name: 'issue',
custom: false,
startEventIdentifier: 'issue_created',
endEventIdentifier: 'issue_stage_end',
};
const initialDefaultStage = {
id: 0,
name: 'issue',
startEventIdentifier: null,
endEventIdentifier: null,
custom: false,
};
const initialCustomStage = {
id: 2,
name: 'custom issue',
startEventIdentifier: 'merge_request_created',
endEventIdentifier: 'merge_request_closed',
startEventLabel: {
id: 'label_added',
},
endEventLabel: {
id: 'label_removed',
},
custom: true,
};
describe('valid default stages', () => {
it.each`
key | value
${'startEventIdentifier'} | ${defaultConfig.startEventIdentifier}
${'endEventIdentifier'} | ${defaultConfig.endEventIdentifier}
${'isDefault'} | ${true}
`('sets the $key field', ({ key, value }) => {
const [res] = generateInitialStageData([defaultConfig], [initialDefaultStage]);
expect(res[key]).toEqual(value);
});
});
it('will return an empty object for an invalid default stages', () => {
const [res] = generateInitialStageData(
[defaultConfig],
[{ ...initialDefaultStage, name: 'issue-fake' }],
);
expect(res).toEqual({});
});
describe('custom stages', () => {
it.each`
key | value
${'startEventIdentifier'} | ${initialCustomStage.startEventIdentifier}
${'endEventIdentifier'} | ${initialCustomStage.endEventIdentifier}
${'startEventLabelId'} | ${initialCustomStage.startEventLabel.id}
${'endEventLabelId'} | ${initialCustomStage.endEventLabel.id}
${'isDefault'} | ${false}
`('sets the $key field', ({ key, value }) => {
const [res] = generateInitialStageData([defaultConfig], [initialCustomStage]);
expect(res[key]).toEqual(value);
});
});
});
import { GlModal } from '@gitlab/ui';
import { GlModal, GlFormInput } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { PRESET_OPTIONS_BLANK } from 'ee/analytics/cycle_analytics/components/create_value_stream_form/constants';
......@@ -6,7 +6,11 @@ import CustomStageFields from 'ee/analytics/cycle_analytics/components/create_va
import DefaultStageFields from 'ee/analytics/cycle_analytics/components/create_value_stream_form/default_stage_fields.vue';
import ValueStreamForm from 'ee/analytics/cycle_analytics/components/value_stream_form.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { customStageEvents as formEvents, defaultStageConfig } from '../mock_data';
import {
convertObjectPropsToCamelCase,
convertObjectPropsToSnakeCase,
} from '~/lib/utils/common_utils';
import { customStageEvents as formEvents, defaultStageConfig, rawCustomStage } from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
......@@ -15,6 +19,7 @@ describe('ValueStreamForm', () => {
let wrapper = null;
const createValueStreamMock = jest.fn(() => Promise.resolve());
const updateValueStreamMock = jest.fn(() => Promise.resolve());
const mockEvent = { preventDefault: jest.fn() };
const mockToastShow = jest.fn();
const streamName = 'Cool stream';
......@@ -28,6 +33,14 @@ describe('ValueStreamForm', () => {
],
};
const initialData = {
stages: [convertObjectPropsToCamelCase(rawCustomStage)],
id: 1337,
name: 'Editable value stream',
};
const initialPreset = PRESET_OPTIONS_BLANK;
const fakeStore = () =>
new Vuex.Store({
state: {
......@@ -35,6 +48,7 @@ describe('ValueStreamForm', () => {
},
actions: {
createValueStream: createValueStreamMock,
updateValueStream: updateValueStreamMock,
},
modules: {
customStages: {
......@@ -71,14 +85,13 @@ describe('ValueStreamForm', () => {
}),
);
const findModal = () => wrapper.find(GlModal);
const findModal = () => wrapper.findComponent(GlModal);
const clickSubmit = () => findModal().vm.$emit('primary', mockEvent);
const clickAddStage = () => findModal().vm.$emit('secondary', mockEvent);
const findExtendedFormFields = () => wrapper.findByTestId('extended-form-fields');
const findPresetSelector = () => wrapper.findByTestId('vsa-preset-selector');
const findBtn = (btn) => findModal().props(btn);
const findSubmitDisabledAttribute = (attribute) =>
findBtn('actionPrimary').attributes[1][attribute];
const findSubmitAttribute = (attribute) => findBtn('actionPrimary').attributes[1][attribute];
const expectFieldError = (testId, error = '') =>
expect(wrapper.findByTestId(testId).attributes('invalid-feedback')).toBe(error);
......@@ -93,7 +106,7 @@ describe('ValueStreamForm', () => {
});
it('submit button is enabled', () => {
expect(findSubmitDisabledAttribute('disabled')).toBeUndefined();
expect(findSubmitAttribute('disabled')).toBeUndefined();
});
it('does not include extended fields', () => {
......@@ -120,6 +133,10 @@ describe('ValueStreamForm', () => {
expect(findExtendedFormFields().exists()).toBe(true);
});
it('sets the submit action text to "Create Value Stream"', () => {
expect(findBtn('actionPrimary').text).toBe('Create Value Stream');
});
describe('Preset selector', () => {
it('has the preset button', () => {
expect(findPresetSelector().exists()).toBe(true);
......@@ -185,6 +202,114 @@ describe('ValueStreamForm', () => {
);
});
});
describe('isEditing=true', () => {
const stageCount = initialData.stages.length;
beforeEach(() => {
wrapper = createComponent({
props: {
initialPreset,
initialData,
isEditing: true,
hasExtendedFormFields: true,
},
});
});
it('does not have the preset button', () => {
expect(findPresetSelector().exists()).toBe(false);
});
it('sets the submit action text to "Save Value Stream"', () => {
expect(findBtn('actionPrimary').text).toBe('Save Value Stream');
});
describe('Add stage button', () => {
it('has the add stage button', () => {
expect(findBtn('actionSecondary')).toMatchObject({ text: 'Add another stage' });
});
it('adds a blank custom stage when clicked', () => {
expect(wrapper.vm.stages.length).toBe(stageCount);
clickAddStage();
expect(wrapper.vm.stages.length).toBe(stageCount + 1);
});
it('validates existing fields when clicked', () => {
expect(wrapper.vm.nameError).toEqual([]);
wrapper.findByTestId('create-value-stream-name').find(GlFormInput).vm.$emit('input', '');
clickAddStage();
expect(wrapper.vm.nameError).toEqual(['Name is required']);
});
});
describe('with valid fields', () => {
beforeEach(() => {
wrapper = createComponent({
props: {
initialPreset,
initialData,
isEditing: true,
hasExtendedFormFields: true,
},
});
});
describe('form submitted successfully', () => {
beforeEach(() => {
clickSubmit();
});
it('calls the "updateValueStreamMock" event when submitted', () => {
expect(updateValueStreamMock).toHaveBeenCalledWith(expect.any(Object), {
...initialData,
stages: initialData.stages.map((stage) =>
convertObjectPropsToSnakeCase(stage, { deep: true }),
),
});
});
it('displays a toast message', () => {
expect(mockToastShow).toHaveBeenCalledWith(
`'${initialData.name}' Value Stream edited`,
{
position: 'top-center',
},
);
});
});
describe('form submission fails', () => {
beforeEach(() => {
wrapper = createComponent({
data: { name: streamName },
props: {
initialFormErrors,
},
});
clickSubmit();
});
it('does not call the updateValueStreamMock action', () => {
expect(updateValueStreamMock).not.toHaveBeenCalled();
});
it('does not clear the name field', () => {
expect(wrapper.vm.name).toBe(streamName);
});
it('does not display a toast message', () => {
expect(mockToastShow).not.toHaveBeenCalled();
});
});
});
});
});
describe('form errors', () => {
......
import { GlButton, GlDropdown } from '@gitlab/ui';
import { GlDropdown } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import ValueStreamSelect from 'ee/analytics/cycle_analytics/components/value_stream_select.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { findDropdownItemText } from '../helpers';
import { valueStreams, defaultStageConfig } from '../mock_data';
......@@ -11,7 +12,6 @@ localVue.use(Vuex);
describe('ValueStreamSelect', () => {
let wrapper = null;
const createValueStreamMock = jest.fn(() => Promise.resolve());
const deleteValueStreamMock = jest.fn(() => Promise.resolve());
const mockEvent = { preventDefault: jest.fn() };
const mockToastShow = jest.fn();
......@@ -32,12 +32,12 @@ describe('ValueStreamSelect', () => {
...initialState,
},
actions: {
createValueStream: createValueStreamMock,
deleteValueStream: deleteValueStreamMock,
},
});
const createComponent = ({ data = {}, initialState = {} } = {}) =>
extendedWrapper(
shallowMount(ValueStreamSelect, {
localVue,
store: fakeStore({ initialState }),
......@@ -51,14 +51,16 @@ describe('ValueStreamSelect', () => {
show: mockToastShow,
},
},
});
}),
);
const findModal = (modal) => wrapper.find(`[data-testid="${modal}-value-stream-modal"]`);
const submitModal = (modal) => findModal(modal).vm.$emit('primary', mockEvent);
const findSelectValueStreamDropdown = () => wrapper.find(GlDropdown);
const findSelectValueStreamDropdown = () => wrapper.findComponent(GlDropdown);
const findSelectValueStreamDropdownOptions = (_wrapper) => findDropdownItemText(_wrapper);
const findCreateValueStreamButton = () => wrapper.find(GlButton);
const findDeleteValueStreamButton = () => wrapper.find('[data-testid="delete-value-stream"]');
const findCreateValueStreamButton = () => wrapper.findByTestId('create-value-stream-button');
const findEditValueStreamButton = () => wrapper.findByTestId('edit-value-stream');
const findDeleteValueStreamButton = () => wrapper.findByTestId('delete-value-stream');
beforeEach(() => {
wrapper = createComponent({
......@@ -89,7 +91,7 @@ describe('ValueStreamSelect', () => {
});
describe('with a selected value stream', () => {
it('renders a delete option for custom value streams', () => {
beforeEach(() => {
wrapper = createComponent({
initialState: {
valueStreams,
......@@ -99,21 +101,31 @@ describe('ValueStreamSelect', () => {
},
},
});
});
it('renders a delete option for custom value streams', () => {
expect(findDeleteValueStreamButton().exists()).toBe(true);
expect(findDeleteValueStreamButton().text()).toBe(`Delete ${selectedValueStream.name}`);
});
it('does not render a delete option for default value streams', () => {
wrapper = createComponent({
initialState: {
valueStreams,
selectedValueStream,
},
it('renders an edit option for custom value streams', () => {
expect(findEditValueStreamButton().exists()).toBe(true);
expect(findEditValueStreamButton().text()).toBe('Edit');
});
});
describe('with a default value stream', () => {
beforeEach(() => {
wrapper = createComponent({ initialState: { valueStreams, selectedValueStream } });
});
it('does not render a delete option for default value streams', () => {
expect(findDeleteValueStreamButton().exists()).toBe(false);
});
it('does not render an edit option for default value streams', () => {
expect(findEditValueStreamButton().exists()).toBe(false);
});
});
});
......@@ -133,6 +145,10 @@ describe('ValueStreamSelect', () => {
it('displays the select value stream dropdown', () => {
expect(findSelectValueStreamDropdown().exists()).toBe(true);
});
it('does not render an edit option for default value streams', () => {
expect(findEditValueStreamButton().exists()).toBe(false);
});
});
describe('No value streams available', () => {
......@@ -151,6 +167,10 @@ describe('ValueStreamSelect', () => {
it('does not display the select value stream dropdown', () => {
expect(findSelectValueStreamDropdown().exists()).toBe(false);
});
it('does not render an edit option for default value streams', () => {
expect(findEditValueStreamButton().exists()).toBe(false);
});
});
describe('Delete value stream modal', () => {
......
......@@ -144,6 +144,7 @@ export const codeEvents = deepCamelCase(stageFixtures.code);
export const testEvents = deepCamelCase(stageFixtures.test);
export const stagingEvents = deepCamelCase(stageFixtures.staging);
export const rawCustomStage = {
name: 'Coolest beans stage',
title: 'Coolest beans stage',
hidden: false,
legend: '',
......
......@@ -3927,9 +3927,6 @@ msgstr ""
msgid "Are you sure you want to close this blocked issue?"
msgstr ""
msgid "Are you sure you want to delete \"%{name}\" Value Stream?"
msgstr ""
msgid "Are you sure you want to delete %{name}?"
msgstr ""
......@@ -8639,6 +8636,9 @@ msgstr ""
msgid "CreateValueStreamForm|'%{name}' Value Stream created"
msgstr ""
msgid "CreateValueStreamForm|'%{name}' Value Stream edited"
msgstr ""
msgid "CreateValueStreamForm|Add another stage"
msgstr ""
......@@ -8651,15 +8651,24 @@ msgstr ""
msgid "CreateValueStreamForm|Code stage start"
msgstr ""
msgid "CreateValueStreamForm|Create Value Stream"
msgstr ""
msgid "CreateValueStreamForm|Create from default template"
msgstr ""
msgid "CreateValueStreamForm|Create from no template"
msgstr ""
msgid "CreateValueStreamForm|Create new Value Stream"
msgstr ""
msgid "CreateValueStreamForm|Default stages"
msgstr ""
msgid "CreateValueStreamForm|Edit Value Stream"
msgstr ""
msgid "CreateValueStreamForm|Editing stage"
msgstr ""
......@@ -8708,6 +8717,9 @@ msgstr ""
msgid "CreateValueStreamForm|Restore stage"
msgstr ""
msgid "CreateValueStreamForm|Save Value Stream"
msgstr ""
msgid "CreateValueStreamForm|Select end event"
msgstr ""
......@@ -9711,6 +9723,15 @@ msgstr ""
msgid "DeleteProject|Failed to restore wiki repository. Please contact the administrator."
msgstr ""
msgid "DeleteValueStream|'%{name}' Value Stream deleted"
msgstr ""
msgid "DeleteValueStream|Are you sure you want to delete \"%{name}\" Value Stream?"
msgstr ""
msgid "DeleteValueStream|Delete %{name}"
msgstr ""
msgid "Deleted"
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