Commit 0da898bf authored by Olena Horal-Koretska's avatar Olena Horal-Koretska

Merge branch '267537-edit-custom-value-streams' into 'master'

[FE] VSA - UI add edit controls to path navigation

See merge request gitlab-org/gitlab!53135
parents 6ea26ab1 6f1ec0d1
......@@ -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