Commit f485cc07 authored by Mark Florian's avatar Mark Florian

Merge branch 'feature-flag-user-list-strategy-fe' into 'master'

Add User List as a Choosable Strategy

See merge request gitlab-org/gitlab!32976
parents 5b65045d 1b96ee31
......@@ -308,43 +308,36 @@ export default {
return axios.put(`${url}/${node.id}`, node);
},
fetchFeatureFlagUserLists(version, id) {
const url = Api.buildUrl(this.featureFlagUserLists)
.replace(':version', version)
.replace(':id', id);
fetchFeatureFlagUserLists(id) {
const url = Api.buildUrl(this.featureFlagUserLists).replace(':id', id);
return axios.get(url);
},
createFeatureFlagUserList(version, id, list) {
const url = Api.buildUrl(this.featureFlagUserLists)
.replace(':version', version)
.replace(':id', id);
createFeatureFlagUserList(id, list) {
const url = Api.buildUrl(this.featureFlagUserLists).replace(':id', id);
return axios.post(url, list);
},
fetchFeatureFlagUserList(version, id, listIid) {
fetchFeatureFlagUserList(id, listIid) {
const url = Api.buildUrl(this.featureFlagUserList)
.replace(':version', version)
.replace(':id', id)
.replace(':list_iid', listIid);
return axios.get(url);
},
updateFeatureFlagUserList(version, id, list) {
updateFeatureFlagUserList(id, list) {
const url = Api.buildUrl(this.featureFlagUserList)
.replace(':version', version)
.replace(':id', id)
.replace(':list_iid', list.iid);
return axios.put(url, list);
},
deleteFeatureFlagUserList(version, id, listIid) {
deleteFeatureFlagUserList(id, listIid) {
const url = Api.buildUrl(this.featureFlagUserList)
.replace(':version', version)
.replace(':id', id)
.replace(':list_iid', listIid);
......
......@@ -31,6 +31,10 @@ export default {
type: String,
required: true,
},
projectId: {
type: String,
required: true,
},
},
translations: {
legacyFlagAlert: s__(
......@@ -101,6 +105,7 @@ export default {
<feature-flag-form
:name="name"
:description="description"
:project-id="projectId"
:scopes="scopes"
:strategies="strategies"
:cancel-path="path"
......
......@@ -10,7 +10,9 @@ import {
GlFormCheckbox,
GlSprintf,
} from '@gitlab/ui';
import Api from 'ee/api';
import { s__ } from '~/locale';
import flash, { FLASH_TYPES } from '~/flash';
import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ToggleButton from '~/vue_shared/components/toggle_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
......@@ -60,6 +62,10 @@ export default {
required: false,
default: '',
},
projectId: {
type: String,
required: true,
},
scopes: {
type: Array,
required: false,
......@@ -118,6 +124,7 @@ export default {
formStrategies: cloneDeep(this.strategies),
newScope: '',
userLists: [],
};
},
computed: {
......@@ -141,6 +148,17 @@ export default {
return this.formStrategies.length > 1;
},
},
mounted() {
if (this.supportsStrategies) {
Api.fetchFeatureFlagUserLists(this.projectId)
.then(({ data }) => {
this.userLists = data;
})
.catch(() => {
flash(s__('FeatureFlags|There was an error retrieving user lists'), FLASH_TYPES.WARNING);
});
}
},
methods: {
addStrategy() {
this.formStrategies.push({ name: '', parameters: {}, scopes: [] });
......@@ -252,13 +270,8 @@ export default {
scope.rolloutUserIds.length > 0 &&
scope.rolloutStrategy === ROLLOUT_STRATEGY_PERCENT_ROLLOUT;
},
onFormStrategyChange({ id, name, parameters, scopes }, index) {
Object.assign(this.filteredStrategies[index], {
id,
name,
parameters,
scopes,
});
onFormStrategyChange(strategy, index) {
Object.assign(this.filteredStrategies[index], strategy);
},
},
};
......@@ -313,6 +326,7 @@ export default {
:index="index"
:endpoint="environmentsEndpoint"
:can-delete="canDeleteStrategy"
:user-lists="userLists"
@change="onFormStrategyChange($event, index)"
@delete="deleteStrategy(strategy)"
/>
......
......@@ -30,6 +30,10 @@ export default {
type: String,
required: true,
},
projectId: {
type: String,
required: true,
},
},
translations: {
newFlagAlert: NEW_FLAG_ALERT,
......@@ -78,6 +82,7 @@ export default {
</div>
<feature-flag-form
:project-id="projectId"
:cancel-path="path"
:submit-text="s__('FeatureFlags|Create feature flag')"
:scopes="scopes"
......
......@@ -16,6 +16,7 @@ import {
ROLLOUT_STRATEGY_ALL_USERS,
ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
ROLLOUT_STRATEGY_USER_ID,
ROLLOUT_STRATEGY_GITLAB_USER_LIST,
} from '../constants';
import NewEnvironmentsDropdown from './new_environments_dropdown.vue';
......@@ -53,10 +54,16 @@ export default {
type: Boolean,
required: true,
},
userLists: {
type: Array,
required: false,
default: () => [],
},
},
ROLLOUT_STRATEGY_ALL_USERS,
ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
ROLLOUT_STRATEGY_USER_ID,
ROLLOUT_STRATEGY_GITLAB_USER_LIST,
translations: {
allEnvironments: __('All environments'),
......@@ -69,6 +76,9 @@ export default {
rolloutPercentageLabel: s__('FeatureFlag|Percentage'),
rolloutUserIdsDescription: __('Enter one or more user ID separated by commas'),
rolloutUserIdsLabel: s__('FeatureFlag|User IDs'),
rolloutUserListLabel: s__('FeatureFlag|List'),
rolloutUserListDescription: s__('FeatureFlag|Select a user list'),
rolloutUserListNoListError: s__('FeatureFlag|There are no configured user lists'),
strategyTypeDescription: __('Select strategy activation method'),
strategyTypeLabel: s__('FeatureFlag|Type'),
},
......@@ -83,6 +93,8 @@ export default {
: '',
formUserIds:
this.strategy.name === ROLLOUT_STRATEGY_USER_ID ? this.strategy.parameters.userIds : '',
formUserListId:
this.strategy.name === ROLLOUT_STRATEGY_GITLAB_USER_LIST ? this.strategy.userListId : '',
strategies: [
{
value: ROLLOUT_STRATEGY_ALL_USERS,
......@@ -96,6 +108,10 @@ export default {
value: ROLLOUT_STRATEGY_USER_ID,
text: __('User IDs'),
},
{
value: ROLLOUT_STRATEGY_GITLAB_USER_LIST,
text: __('List'),
},
],
};
},
......@@ -109,6 +125,9 @@ export default {
strategyUserIdsId() {
return `strategy-user-ids-${this.index}`;
},
strategyUserListId() {
return `strategy-user-list-${this.index}`;
},
environmentsDropdownId() {
return `environments-dropdown-${this.index}`;
},
......@@ -118,6 +137,9 @@ export default {
isUserWithId() {
return this.isStrategyType(ROLLOUT_STRATEGY_USER_ID);
},
isUserList() {
return this.isStrategyType(ROLLOUT_STRATEGY_GITLAB_USER_LIST);
},
appliesToAllEnvironments() {
return (
this.filteredEnvironments.length === 0 ||
......@@ -128,6 +150,12 @@ export default {
filteredEnvironments() {
return this.environments.filter(e => !e.shouldBeDestroyed);
},
userListOptions() {
return this.userLists.map(({ name, id }) => ({ value: id, text: name }));
},
hasUserLists() {
return this.userListOptions.length > 0;
},
},
methods: {
addEnvironment(environment) {
......@@ -140,6 +168,10 @@ export default {
},
onStrategyChange() {
const parameters = {};
const strategy = {
...this.formStrategy,
scopes: this.environments,
};
switch (this.formStrategy.name) {
case ROLLOUT_STRATEGY_PERCENT_ROLLOUT:
parameters.percentage = this.formPercentage;
......@@ -148,13 +180,15 @@ export default {
case ROLLOUT_STRATEGY_USER_ID:
parameters.userIds = this.formUserIds;
break;
case ROLLOUT_STRATEGY_GITLAB_USER_LIST:
strategy.userListId = this.formUserListId;
break;
default:
break;
}
this.$emit('change', {
...this.formStrategy,
...strategy,
parameters,
scopes: this.environments,
});
},
removeScope(environment) {
......@@ -189,7 +223,7 @@ export default {
</gl-form-group>
</div>
<div>
<div data-testid="strategy">
<gl-form-group
v-if="isPercentRollout"
:label="$options.translations.rolloutPercentageLabel"
......@@ -221,6 +255,21 @@ export default {
@input="onStrategyChange"
/>
</gl-form-group>
<gl-form-group
v-if="isUserList"
:state="hasUserLists"
:invalid-feedback="$options.translations.rolloutUserListNoListError"
:label="$options.translations.rolloutUserListLabel"
:description="$options.translations.rolloutUserListDescription"
:label-for="strategyUserListId"
>
<gl-form-select
:id="strategyUserListId"
v-model="formUserListId"
:options="userListOptions"
@change="onStrategyChange"
/>
</gl-form-group>
</div>
<div class="align-self-end align-self-md-stretch order-first offset-md-0 order-md-0 ml-auto">
......
......@@ -4,6 +4,7 @@ import { s__ } from '~/locale';
export const ROLLOUT_STRATEGY_ALL_USERS = 'default';
export const ROLLOUT_STRATEGY_PERCENT_ROLLOUT = 'gradualRolloutUserId';
export const ROLLOUT_STRATEGY_USER_ID = 'userWithId';
export const ROLLOUT_STRATEGY_GITLAB_USER_LIST = 'gitlabUserList';
export const PERCENT_ROLLOUT_GROUP_ID = 'default';
......
......@@ -15,6 +15,7 @@ export default () => {
endpoint: el.dataset.endpoint,
path: el.dataset.featureFlagsPath,
environmentsEndpoint: el.dataset.environmentsEndpoint,
projectId: el.dataset.projectId,
},
});
},
......
......@@ -15,6 +15,7 @@ export default () => {
endpoint: el.dataset.endpoint,
path: el.dataset.featureFlagsPath,
environmentsEndpoint: el.dataset.environmentsEndpoint,
projectId: el.dataset.projectId,
},
});
},
......
......@@ -3,6 +3,7 @@ import {
ROLLOUT_STRATEGY_ALL_USERS,
ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
ROLLOUT_STRATEGY_USER_ID,
ROLLOUT_STRATEGY_GITLAB_USER_LIST,
INTERNAL_ID_PREFIX,
DEFAULT_PERCENT_ROLLOUT,
PERCENT_ROLLOUT_GROUP_ID,
......@@ -173,6 +174,7 @@ export const mapStrategiesToViewModel = strategiesFromRails =>
id: s.id,
name: s.name,
parameters: mapStrategiesParametersToViewModel(s.parameters),
userListId: s.user_list?.id,
// eslint-disable-next-line no-underscore-dangle
shouldBeDestroyed: Boolean(s._destroy),
scopes: mapStrategyScopesToView(s.scopes),
......@@ -185,18 +187,27 @@ const mapStrategiesParametersToRails = params => {
return params;
};
const mapStrategyToRails = strategy => {
const mappedStrategy = {
id: strategy.id,
name: strategy.name,
_destroy: strategy.shouldBeDestroyed,
scopes_attributes: mapStrategyScopesToRails(strategy.scopes || []),
parameters: mapStrategiesParametersToRails(strategy.parameters),
};
if (strategy.name === ROLLOUT_STRATEGY_GITLAB_USER_LIST) {
mappedStrategy.user_list_id = strategy.userListId;
}
return mappedStrategy;
};
export const mapStrategiesToRails = params => ({
operations_feature_flag: {
name: params.name,
description: params.description,
version: params.version,
active: params.active,
strategies_attributes: (params.strategies || []).map(s => ({
id: s.id,
name: s.name,
parameters: mapStrategiesParametersToRails(s.parameters),
_destroy: s.shouldBeDestroyed,
scopes_attributes: mapStrategyScopesToRails(s.scopes || []),
})),
strategies_attributes: (params.strategies || []).map(mapStrategyToRails),
},
});
......@@ -2,4 +2,7 @@
- breadcrumb_title @feature_flag.name
- page_title s_('FeatureFlags|Edit Feature Flag')
#js-edit-feature-flag{ data: { endpoint: project_feature_flag_path(@project, @feature_flag), feature_flags_path: project_feature_flags_path(@project), environments_endpoint: search_project_environments_path(@project, format: :json)} }
#js-edit-feature-flag{ data: { endpoint: project_feature_flag_path(@project, @feature_flag),
project_id: @project.id,
feature_flags_path: project_feature_flags_path(@project),
environments_endpoint: search_project_environments_path(@project, format: :json)} }
......@@ -3,4 +3,7 @@
- breadcrumb_title s_('FeatureFlags|New')
- page_title s_('FeatureFlags|New Feature Flag')
#js-new-feature-flag{ data: { endpoint: project_feature_flags_path(@project, format: :json), feature_flags_path: project_feature_flags_path(@project), environments_endpoint: search_project_environments_path(@project, format: :json) } }
#js-new-feature-flag{ data: { endpoint: project_feature_flags_path(@project, format: :json),
feature_flags_path: project_feature_flags_path(@project),
environments_endpoint: search_project_environments_path(@project, format: :json),
project_id: @project.id } }
......@@ -788,7 +788,7 @@ describe('Api', () => {
it('GETs the right url', () => {
mock.onGet(expectedUrl).replyOnce(200, []);
return Api.fetchFeatureFlagUserLists(dummyApiVersion, projectId).then(({ data }) => {
return Api.fetchFeatureFlagUserLists(projectId).then(({ data }) => {
expect(data).toEqual([]);
});
});
......@@ -802,11 +802,9 @@ describe('Api', () => {
};
mock.onPost(expectedUrl, mockUserListData).replyOnce(200, mockUserList);
return Api.createFeatureFlagUserList(dummyApiVersion, projectId, mockUserListData).then(
({ data }) => {
expect(data).toEqual(mockUserList);
},
);
return Api.createFeatureFlagUserList(projectId, mockUserListData).then(({ data }) => {
expect(data).toEqual(mockUserList);
});
});
});
......@@ -814,7 +812,7 @@ describe('Api', () => {
it('GETs the right url', () => {
mock.onGet(`${expectedUrl}/1`).replyOnce(200, mockUserList);
return Api.fetchFeatureFlagUserList(dummyApiVersion, projectId, 1).then(({ data }) => {
return Api.fetchFeatureFlagUserList(projectId, 1).then(({ data }) => {
expect(data).toEqual(mockUserList);
});
});
......@@ -824,7 +822,7 @@ describe('Api', () => {
it('PUTs the right url', () => {
mock.onPut(`${expectedUrl}/1`).replyOnce(200, { ...mockUserList, user_xids: '5' });
return Api.updateFeatureFlagUserList(dummyApiVersion, projectId, {
return Api.updateFeatureFlagUserList(projectId, {
...mockUserList,
user_xids: '5',
}).then(({ data }) => {
......@@ -837,7 +835,7 @@ describe('Api', () => {
it('DELETEs the right url', () => {
mock.onDelete(`${expectedUrl}/1`).replyOnce(200, 'deleted');
return Api.deleteFeatureFlagUserList(dummyApiVersion, projectId, 1).then(({ data }) => {
return Api.deleteFeatureFlagUserList(projectId, 1).then(({ data }) => {
expect(data).toBe('deleted');
});
});
......
......@@ -33,6 +33,7 @@ describe('Edit feature flag form', () => {
endpoint: `${TEST_HOST}/feature_flags.json`,
path: '/feature_flags',
environmentsEndpoint: 'environments.json',
projectId: '8',
},
store,
provide: {
......
import { uniqueId } from 'lodash';
import { shallowMount } from '@vue/test-utils';
import { GlFormTextarea, GlFormCheckbox, GlDeprecatedButton } from '@gitlab/ui';
import Api from 'ee/api';
import Form from 'ee/feature_flags/components/form.vue';
import EnvironmentsDropdown from 'ee/feature_flags/components/environments_dropdown.vue';
import Strategy from 'ee/feature_flags/components/strategy.vue';
......@@ -13,7 +14,9 @@ import {
NEW_VERSION_FLAG,
} from 'ee/feature_flags/constants';
import ToggleButton from '~/vue_shared/components/toggle_button.vue';
import { featureFlag } from '../mock_data';
import { featureFlag, userList } from '../mock_data';
jest.mock('ee/api.js');
describe('feature flag form', () => {
let wrapper;
......@@ -21,6 +24,7 @@ describe('feature flag form', () => {
cancelPath: 'feature_flags',
submitText: 'Create',
environmentsEndpoint: '/environments.json',
projectId: '1',
};
const factory = (props = {}) => {
......@@ -35,6 +39,10 @@ describe('feature flag form', () => {
});
};
beforeEach(() => {
Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [] });
});
afterEach(() => {
wrapper.destroy();
});
......@@ -388,6 +396,7 @@ describe('feature flag form', () => {
describe('with strategies', () => {
beforeEach(() => {
Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [userList] });
factory({
...requiredProps,
name: featureFlag.name,
......@@ -409,6 +418,12 @@ describe('feature flag form', () => {
});
});
it('should request the user lists on mount', () => {
return wrapper.vm.$nextTick(() => {
expect(Api.fetchFeatureFlagUserLists).toHaveBeenCalledWith('1');
});
});
it('should show the strategy component', () => {
const strategy = wrapper.find(Strategy);
expect(strategy.exists()).toBe(true);
......@@ -440,5 +455,9 @@ describe('feature flag form', () => {
expect(wrapper.find(Strategy).props('strategy')).not.toEqual(strategy);
});
});
it('should provide the user lists to the strategy', () => {
expect(wrapper.find(Strategy).props('userLists')).toEqual([userList]);
});
});
});
......@@ -25,6 +25,7 @@ describe('New feature flag form', () => {
endpoint: 'feature_flags.json',
path: '/feature_flags',
environmentsEndpoint: 'environments.json',
projectId: '8',
},
store,
});
......@@ -72,4 +73,8 @@ describe('New feature flag form', () => {
it('should alert users that feature flags are changing soon', () => {
expect(wrapper.find(GlAlert).text()).toBe(NEW_FLAG_ALERT);
});
it('should pass in the project ID', () => {
expect(wrapper.find(Form).props('projectId')).toBe('8');
});
});
......@@ -5,13 +5,18 @@ import {
ROLLOUT_STRATEGY_ALL_USERS,
ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
ROLLOUT_STRATEGY_USER_ID,
ROLLOUT_STRATEGY_GITLAB_USER_LIST,
} from 'ee/feature_flags/constants';
import Strategy from 'ee/feature_flags/components/strategy.vue';
import NewEnvironmentsDropdown from 'ee/feature_flags/components/new_environments_dropdown.vue';
import { userList } from '../mock_data';
describe('Feature flags strategy', () => {
let wrapper;
const findStrategy = () => wrapper.find('[data-testid="strategy"]');
const factory = (
opts = {
propsData: {
......@@ -19,6 +24,7 @@ describe('Feature flags strategy', () => {
index: 0,
endpoint: '',
canDelete: true,
userLists: [userList],
},
},
) => {
......@@ -37,11 +43,11 @@ describe('Feature flags strategy', () => {
});
describe.each`
name | parameter | value | input
${ROLLOUT_STRATEGY_ALL_USERS} | ${null} | ${null} | ${null}
${ROLLOUT_STRATEGY_PERCENT_ROLLOUT} | ${'percentage'} | ${'50'} | ${GlFormInput}
${ROLLOUT_STRATEGY_USER_ID} | ${'userIds'} | ${'1,2'} | ${GlFormTextarea}
`('with strategy $name', ({ name, parameter, value, input }) => {
name | parameter | value | newValue | input
${ROLLOUT_STRATEGY_ALL_USERS} | ${null} | ${null} | ${null} | ${null}
${ROLLOUT_STRATEGY_PERCENT_ROLLOUT} | ${'percentage'} | ${'50'} | ${'20'} | ${GlFormInput}
${ROLLOUT_STRATEGY_USER_ID} | ${'userIds'} | ${'1,2'} | ${'1,2,3'} | ${GlFormTextarea}
`('with strategy $name', ({ name, parameter, value, newValue, input }) => {
let propsData;
let strategy;
beforeEach(() => {
......@@ -57,27 +63,74 @@ describe('Feature flags strategy', () => {
it('should set the select to match the strategy name', () => {
expect(wrapper.find(GlFormSelect).attributes('value')).toBe(name);
});
it('should not show inputs for other paramters', () => {
[GlFormTextarea, GlFormInput]
it('should not show inputs for other parameters', () => {
[GlFormTextarea, GlFormInput, GlFormSelect]
.filter(component => component !== input)
.map(component => wrapper.findAll(component))
.map(component => findStrategy().findAll(component))
.forEach(inputWrapper => expect(inputWrapper).toHaveLength(0));
});
if (parameter !== null) {
it(`should show the input for ${parameter} with the correct value`, () => {
const inputWrapper = wrapper.find(input);
const inputWrapper = findStrategy().find(input);
expect(inputWrapper.exists()).toBe(true);
expect(inputWrapper.attributes('value')).toBe(value);
});
it(`should emit a change event when altering ${parameter}`, () => {
const inputWrapper = wrapper.find(input);
inputWrapper.vm.$emit('input', '');
const inputWrapper = findStrategy().find(input);
inputWrapper.vm.$emit('input', newValue);
expect(wrapper.emitted('change')).toEqual([
[{ name, parameters: expect.objectContaining({ [parameter]: '' }), scopes: [] }],
[{ name, parameters: expect.objectContaining({ [parameter]: newValue }), scopes: [] }],
]);
});
}
});
describe('with strategy gitlabUserList', () => {
let propsData;
let strategy;
beforeEach(() => {
strategy = { name: ROLLOUT_STRATEGY_GITLAB_USER_LIST, userListId: '2', parameters: {} };
propsData = { strategy, index: 0, endpoint: '', canDelete: true, userLists: [userList] };
factory({ propsData });
});
it('should set the select to match the strategy name', () => {
expect(wrapper.find(GlFormSelect).attributes('value')).toBe(
ROLLOUT_STRATEGY_GITLAB_USER_LIST,
);
});
it('should not show inputs for other parameters', () => {
expect(findStrategy().contains(GlFormTextarea)).toBe(false);
expect(findStrategy().contains(GlFormInput)).toBe(false);
});
it('should show the input for userListId with the correct value', () => {
const inputWrapper = findStrategy().find(GlFormSelect);
expect(inputWrapper.exists()).toBe(true);
expect(inputWrapper.attributes('value')).toBe('2');
});
it('should emit a change event when altering the userListId', () => {
const inputWrapper = findStrategy().find(GlFormSelect);
inputWrapper.vm.$emit('input', '3');
inputWrapper.vm.$emit('change', '3');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('change')).toEqual([
[
{
name: ROLLOUT_STRATEGY_GITLAB_USER_LIST,
userListId: '3',
scopes: [],
parameters: {},
},
],
]);
});
});
});
describe('with a strategy', () => {
describe('with scopes defined', () => {
......
......@@ -89,3 +89,13 @@ export const getRequestData = {
};
export const rotateData = { token: 'oP6sCNRqtRHmpy1gw2-F' };
export const userList = {
name: 'test_users',
user_xids: 'user3,user4,user5',
id: 2,
iid: 2,
project_id: 1,
created_at: '2020-02-04T08:13:10.507Z',
updated_at: '2020-02-04T08:13:10.507Z',
};
......@@ -9623,6 +9623,9 @@ msgstr ""
msgid "FeatureFlags|There was an error fetching the feature flags."
msgstr ""
msgid "FeatureFlags|There was an error retrieving user lists"
msgstr ""
msgid "FeatureFlags|Try again in a few moments or contact your support team."
msgstr ""
......@@ -9632,9 +9635,18 @@ msgstr ""
msgid "FeatureFlag|Delete strategy"
msgstr ""
msgid "FeatureFlag|List"
msgstr ""
msgid "FeatureFlag|Percentage"
msgstr ""
msgid "FeatureFlag|Select a user list"
msgstr ""
msgid "FeatureFlag|There are no configured user lists"
msgstr ""
msgid "FeatureFlag|Type"
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