Unverified Commit 29393825 authored by Tamika Tannis's avatar Tamika Tannis Committed by GitHub

Improve granularity of logging search actions (#396)

* WIP: One approach

* Second approach

* Fix a few errors; Add tests

* Fix test

* Code cleanup

* Change value for when user selects inline result & searchAll is needed

* Update SearchType enum

* Use snake_case for consistency in backend until we have some auto-convert
parent 5aaec51b
This diff is collapsed.
......@@ -23,11 +23,11 @@ def map_table_result(result: Dict) -> Dict:
}
def generate_query_json(*, filters: Dict = {}, page_index: str, search_term: str) -> Dict:
def generate_query_json(*, filters: Dict = {}, page_index: int, search_term: str) -> Dict:
"""
Transforms the given paramaters to the query json for the search service according to
the api defined at:
TODO (ttannis): Add link when amundsensearch PR is complete
https://github.com/lyft/amundsensearchlibrary/blob/master/search_service/api/swagger_doc/table/search_table_filter.yml
"""
# Generate the filter payload
filter_payload = {}
......
......@@ -4,7 +4,7 @@ import { DashboardSearchResults, TableSearchResults, UserSearchResults } from 'd
import globalState from 'fixtures/globalState';
import { ResourceType } from 'interfaces';
import { ResourceType, SearchType } from 'interfaces';
import * as API from '../v0';
......@@ -44,7 +44,7 @@ describe('searchResource', () => {
const resourceType = ResourceType.dashboard;
const term = 'test';
expect.assertions(3);
await API.searchResource(pageIndex, resourceType, term).then(results => {
await API.searchResource(pageIndex, resourceType, term, undefined, SearchType.FILTER).then(results => {
expect(results).toEqual({});
});
expect(axiosMockGet).not.toHaveBeenCalled();
......@@ -59,7 +59,7 @@ describe('searchResource', () => {
const resourceType = ResourceType.user;
const term = 'test';
expect.assertions(3);
await API.searchResource(pageIndex, resourceType, term).then(results => {
await API.searchResource(pageIndex, resourceType, term, undefined, SearchType.FILTER).then(results => {
expect(results).toEqual({});
});
expect(axiosMockGet).not.toHaveBeenCalled();
......@@ -74,14 +74,15 @@ describe('searchResource', () => {
const pageIndex = 0;
const resourceType = ResourceType.user;
const term = 'test';
await API.searchResource(pageIndex, resourceType, term);
expect(axiosMockGet).toHaveBeenCalledWith(`${API.BASE_URL}/${resourceType}?query=${term}&page_index=${pageIndex}`);
const searchType = SearchType.SUBMIT_TERM;
await API.searchResource(pageIndex, resourceType, term, undefined, searchType);
expect(axiosMockGet).toHaveBeenCalledWith(`${API.BASE_URL}/${resourceType}?query=${term}&page_index=${pageIndex}&search_type=${searchType}`);
expect(axiosMockPost).not.toHaveBeenCalled();
});
it('calls searchResourceHelper with api call response', async () => {
const searchResourceHelperSpy = jest.spyOn(API, 'searchResourceHelper');
await API.searchResource(0, ResourceType.user, 'test');
await API.searchResource(0, ResourceType.user, 'test', undefined, SearchType.FILTER);
expect(searchResourceHelperSpy).toHaveBeenCalledWith(mockSearchResponse);
});
})
......@@ -94,18 +95,20 @@ describe('searchResource', () => {
const resourceType = ResourceType.table;
const term = 'test';
const filters = { 'schema': 'schema_name' }
await API.searchResource(pageIndex, resourceType, term, filters);
const searchType = SearchType.SUBMIT_TERM;
await API.searchResource(pageIndex, resourceType, term, filters, searchType);
expect(axiosMockGet).not.toHaveBeenCalled();
expect(axiosMockPost).toHaveBeenCalledWith(`${API.BASE_URL}/${resourceType}_qs`, {
expect(axiosMockPost).toHaveBeenCalledWith(`${API.BASE_URL}/${resourceType}`, {
filters,
pageIndex,
term,
searchType,
});
});
it('calls searchResourceHelper with api call response', async () => {
const searchResourceHelperSpy = jest.spyOn(API, 'searchResourceHelper');
await API.searchResource(0, ResourceType.table, 'test', { 'schema': 'schema_name' });
await API.searchResource(0, ResourceType.table, 'test', { 'schema': 'schema_name' }, SearchType.FILTER);
expect(searchResourceHelperSpy).toHaveBeenCalledWith(mockSearchResponse);
});
})
......
import axios, { AxiosResponse } from 'axios';
import { indexUsersEnabled } from 'config/config-utils';
import { ResourceType } from 'interfaces';
import { ResourceType, SearchType } from 'interfaces';
import { DashboardSearchResults, TableSearchResults, UserSearchResults } from '../types';
......@@ -29,7 +29,7 @@ export const searchResourceHelper = (response: AxiosResponse<SearchAPI>) => {
return ret;
};
export function searchResource(pageIndex: number, resource: ResourceType, term: string, filters: ResourceFilterReducerState = {}) {
export function searchResource(pageIndex: number, resource: ResourceType, term: string, filters: ResourceFilterReducerState = {}, searchType: SearchType) {
if (resource === ResourceType.dashboard ||
(resource === ResourceType.user && !indexUsersEnabled())) {
return Promise.resolve({});
......@@ -37,12 +37,13 @@ export function searchResource(pageIndex: number, resource: ResourceType, term:
/* Note: This logic must exist until query string endpoints are created for all resources */
if (resource === ResourceType.table) {
return axios.post(`${BASE_URL}/${resource}_qs`, {
return axios.post(`${BASE_URL}/${resource}`, {
filters,
pageIndex,
term,
searchType,
}).then(searchResourceHelper);
}
return axios.get(`${BASE_URL}/${resource}?query=${term}&page_index=${pageIndex}`)
return axios.get(`${BASE_URL}/${resource}?query=${term}&page_index=${pageIndex}&search_type=${searchType}`)
.then(searchResourceHelper);
};
import { ResourceType } from 'interfaces';
import { ResourceType, SearchType} from 'interfaces';
import { Search as UrlSearch } from 'history';
......@@ -49,13 +49,14 @@ export interface SearchReducerState {
};
/* ACTIONS */
export function searchAll(term: string, resource?: ResourceType, pageIndex?: number, useFilters: boolean = false): SearchAllRequest {
export function searchAll(searchType: SearchType, term: string, resource?: ResourceType, pageIndex?: number, useFilters: boolean = false): SearchAllRequest {
return {
payload: {
resource,
pageIndex,
term,
useFilters,
searchType,
},
type: SearchAll.REQUEST,
};
......@@ -67,12 +68,13 @@ export function searchAllFailure(): SearchAllResponse {
return { type: SearchAll.FAILURE };
};
export function searchResource(term: string, resource: ResourceType, pageIndex: number): SearchResourceRequest {
export function searchResource(searchType: SearchType, term: string, resource: ResourceType, pageIndex: number): SearchResourceRequest {
return {
payload: {
pageIndex,
term,
resource,
searchType
},
type: SearchResource.REQUEST,
};
......
......@@ -3,7 +3,7 @@ import { all, call, debounce, put, select, takeEvery, takeLatest } from 'redux-s
import * as _ from 'lodash';
import * as qs from 'simple-query-string';
import { ResourceType } from 'interfaces/Resources';
import { ResourceType, SearchType } from 'interfaces';
import * as API from './api/v0';
......@@ -68,7 +68,7 @@ export function* filterWorker(): SagaIterator {
const state = yield select(getSearchState);
const { search_term, selectedTab, filters } = state;
const pageIndex = getPageIndex(state)
yield put(searchResource(search_term, selectedTab, pageIndex));
yield put(searchResource(SearchType.FILTER, search_term, selectedTab, pageIndex));
updateSearchUrl({ filters, resource: selectedTab, term: search_term, index: pageIndex }, true);
};
......@@ -90,7 +90,7 @@ export function* filterWatcher2(): SagaIterator {
export function* filterWorker2(action: any): SagaIterator {
const state = yield select(getSearchState);
const { pageIndex = 0, resourceType, term = '' } = action.payload;
yield put(searchResource(term, resourceType, pageIndex));
yield put(searchResource(SearchType.FILTER, term, resourceType, pageIndex));
updateSearchUrl({ term, filters: state.filters, resource: resourceType, index: pageIndex }, false);
};
......@@ -98,8 +98,8 @@ export function* inlineSearchWorker(action: InlineSearchRequest): SagaIterator {
const { term } = action.payload;
try {
const [tableResponse, userResponse] = yield all([
call(API.searchResource, 0, ResourceType.table, term),
call(API.searchResource, 0, ResourceType.user, term),
call(API.searchResource, 0, ResourceType.table, term, {}, SearchType.INLINE_SEARCH),
call(API.searchResource, 0, ResourceType.user, term, {}, SearchType.INLINE_SEARCH),
]);
const inlineSearchResponse = {
tables: tableResponse.tables || initialInlineResultsState.tables,
......@@ -124,7 +124,7 @@ export function* selectInlineResultWorker(action): SagaIterator {
const state = yield select();
const { searchTerm, resourceType, updateUrl } = action.payload;
if (state.search.inlineResults.isLoading) {
yield put(searchAll(searchTerm, resourceType, 0))
yield put(searchAll(SearchType.INLINE_SELECT, searchTerm, resourceType, 0, false))
updateSearchUrl({ term: searchTerm, filters: state.search.filters });
}
else {
......@@ -144,67 +144,10 @@ export function* selectInlineResultsWatcher(): SagaIterator {
yield takeEvery(InlineSearch.SELECT, selectInlineResultWorker);
};
export function* searchAllWorker(action: SearchAllRequest): SagaIterator {
let { resource } = action.payload;
const { pageIndex, term, useFilters } = action.payload;
if (!useFilters) {
yield put(clearAllFilters())
}
const state = yield select(getSearchState);
const tableIndex = resource === ResourceType.table ? pageIndex : 0;
const userIndex = resource === ResourceType.user ? pageIndex : 0;
const dashboardIndex = resource === ResourceType.dashboard ? pageIndex : 0;
try {
const [tableResponse, userResponse, dashboardResponse] = yield all([
call(API.searchResource, tableIndex, ResourceType.table, term, state.filters[ResourceType.table]),
call(API.searchResource, userIndex, ResourceType.user, term, state.filters[ResourceType.user]),
call(API.searchResource, dashboardIndex, ResourceType.dashboard, term, state.filters[ResourceType.dashboard]),
]);
const searchAllResponse = {
search_term: term,
selectedTab: resource,
tables: tableResponse.tables || initialState.tables,
users: userResponse.users || initialState.users,
dashboards: dashboardResponse.dashboards || initialState.dashboards,
isLoading: false,
};
if (resource === undefined) {
resource = autoSelectResource(searchAllResponse);
searchAllResponse.selectedTab = resource;
}
const index = getPageIndex(searchAllResponse);
yield put(searchAllSuccess(searchAllResponse));
updateSearchUrl({ term, resource, index, filters: state.filters }, true);
} catch (e) {
yield put(searchAllFailure());
}
};
export function* searchAllWatcher(): SagaIterator {
yield takeEvery(SearchAll.REQUEST, searchAllWorker);
};
export function* searchResourceWorker(action: SearchResourceRequest): SagaIterator {
const { pageIndex, resource, term } = action.payload;
const state = yield select(getSearchState);
try {
const searchResults = yield call(API.searchResource, pageIndex, resource, term, state.filters[resource]);
yield put(searchResourceSuccess(searchResults));
} catch (e) {
yield put(searchResourceFailure());
}
};
export function* searchResourceWatcher(): SagaIterator {
yield takeEvery(SearchResource.REQUEST, searchResourceWorker);
};
export function* submitSearchWorker(action: SubmitSearchRequest): SagaIterator {
const state = yield select(getSearchState);
const { searchTerm, useFilters } = action.payload;
yield put(searchAll(searchTerm, undefined, undefined, useFilters));
yield put(searchAll(SearchType.SUBMIT_TERM, searchTerm, undefined, undefined, useFilters));
updateSearchUrl({ term: searchTerm, filters: state.filters });
};
export function* submitSearchWatcher(): SagaIterator {
......@@ -230,7 +173,7 @@ export function* setResourceWatcher(): SagaIterator {
export function* setPageIndexWorker(action: SetPageIndexRequest): SagaIterator {
const { pageIndex, updateUrl } = action.payload;
const state = yield select(getSearchState);
yield put(searchResource(state.search_term, state.selectedTab, pageIndex));
yield put(searchResource(SearchType.PAGINATION, state.search_term, state.selectedTab, pageIndex));
if (updateUrl) {
updateSearchUrl({
......@@ -249,7 +192,7 @@ export function* clearSearchWorker(action: ClearSearchRequest): SagaIterator {
/* If there was a previous search term, search each resource using filters */
const state = yield select(getSearchState);
if (!!state.search_term) {
yield put(searchAll('', undefined, undefined, true));
yield put(searchAll(SearchType.CLEAR_TERM, '', undefined, undefined, true));
}
};
export function* clearSearchWatcher(): SagaIterator {
......@@ -264,7 +207,7 @@ export function* urlDidUpdateWorker(action: UrlDidUpdateRequest): SagaIterator {
const state = yield select(getSearchState);
if (!!term && state.search_term !== term) {
yield put(searchAll(term, resource, parsedIndex));
yield put(searchAll(SearchType.LOAD_URL, term, resource, parsedIndex));
} else if (!!resource) {
if (resource !== state.selectedTab) {
yield put(setResource(resource, false))
......@@ -302,3 +245,66 @@ export function* loadPreviousSearchWorker(action: LoadPreviousSearchRequest): Sa
export function* loadPreviousSearchWatcher(): SagaIterator {
yield takeEvery(LoadPreviousSearch.REQUEST, loadPreviousSearchWorker);
};
//////////////////////////////////////////////////////////////////////////////
// API/END SAGAS
// These sagas directly trigger axios search requests.
// The actions that trigger them should only be fired by other sagas,
// and these sagas should be considered the "end" of any saga chain.
//////////////////////////////////////////////////////////////////////////////
export function* searchResourceWorker(action: SearchResourceRequest): SagaIterator {
const { pageIndex, resource, term, searchType } = action.payload;
const state = yield select(getSearchState);
try {
const searchResults = yield call(API.searchResource, pageIndex, resource, term, state.filters[resource], searchType);
yield put(searchResourceSuccess(searchResults));
} catch (e) {
yield put(searchResourceFailure());
}
};
export function* searchResourceWatcher(): SagaIterator {
yield takeEvery(SearchResource.REQUEST, searchResourceWorker);
};
export function* searchAllWorker(action: SearchAllRequest): SagaIterator {
let { resource } = action.payload;
const { pageIndex, term, useFilters, searchType } = action.payload;
if (!useFilters) {
yield put(clearAllFilters())
}
const state = yield select(getSearchState);
const tableIndex = resource === ResourceType.table ? pageIndex : 0;
const userIndex = resource === ResourceType.user ? pageIndex : 0;
const dashboardIndex = resource === ResourceType.dashboard ? pageIndex : 0;
try {
const [tableResponse, userResponse, dashboardResponse] = yield all([
call(API.searchResource, tableIndex, ResourceType.table, term, state.filters[ResourceType.table], searchType),
call(API.searchResource, userIndex, ResourceType.user, term, state.filters[ResourceType.user], searchType),
call(API.searchResource, dashboardIndex, ResourceType.dashboard, term, state.filters[ResourceType.dashboard], searchType),
]);
const searchAllResponse = {
search_term: term,
selectedTab: resource,
tables: tableResponse.tables || initialState.tables,
users: userResponse.users || initialState.users,
dashboards: dashboardResponse.dashboards || initialState.dashboards,
isLoading: false,
};
if (resource === undefined) {
resource = autoSelectResource(searchAllResponse);
searchAllResponse.selectedTab = resource;
}
const index = getPageIndex(searchAllResponse);
yield put(searchAllSuccess(searchAllResponse));
updateSearchUrl({ term, resource, index, filters: state.filters }, true);
} catch (e) {
yield put(searchAllFailure());
}
};
export function* searchAllWatcher(): SagaIterator {
yield takeEvery(SearchAll.REQUEST, searchAllWorker);
};
import { testSaga } from 'redux-saga-test-plan';
import { debounce } from 'redux-saga/effects';
import { DEFAULT_RESOURCE_TYPE, ResourceType } from 'interfaces';
import { DEFAULT_RESOURCE_TYPE, ResourceType, SearchType } from 'interfaces';
import * as NavigationUtils from 'utils/navigationUtils';
import * as SearchUtils from 'ducks/search/utils';
......@@ -146,26 +146,30 @@ describe('search ducks', () => {
const term = 'test';
const resource = ResourceType.table;
const pageIndex = 0;
const action = searchAll(term, resource, pageIndex);
const searchType = SearchType.SUBMIT_TERM;
const action = searchAll(searchType, term, resource, pageIndex);
const { payload } = action;
expect(action.type).toBe(SearchAll.REQUEST);
expect(payload.resource).toBe(resource);
expect(payload.term).toBe(term);
expect(payload.pageIndex).toBe(pageIndex);
expect(payload.useFilters).toBe(false);
expect(payload.searchType).toBe(searchType);
});
it('searchAll - returns the action to search all resources with useFilters', () => {
const term = 'test';
const resource = ResourceType.table;
const pageIndex = 0;
const action = searchAll(term, resource, pageIndex, true);
const searchType = SearchType.SUBMIT_TERM;
const action = searchAll(searchType, term, resource, pageIndex, true);
const { payload } = action;
expect(action.type).toBe(SearchAll.REQUEST);
expect(payload.resource).toBe(resource);
expect(payload.term).toBe(term);
expect(payload.pageIndex).toBe(pageIndex);
expect(payload.useFilters).toBe(true);
expect(payload.searchType).toBe(searchType);
});
it('searchAllSuccess - returns the action to process the success', () => {
......@@ -184,12 +188,14 @@ describe('search ducks', () => {
const term = 'test';
const resource = ResourceType.table;
const pageIndex = 0;
const action = searchResource(term, resource, pageIndex);
const searchType = SearchType.SUBMIT_TERM;
const action = searchResource(searchType, term, resource, pageIndex);
const { payload } = action;
expect(action.type).toBe(SearchResource.REQUEST);
expect(payload.resource).toBe(resource);
expect(payload.term).toBe(term);
expect(payload.pageIndex).toBe(pageIndex);
expect(payload.searchType).toBe(searchType);
});
it('searchResourceSuccess - returns the action to process the success', () => {
......@@ -309,11 +315,9 @@ describe('search ducks', () => {
expect(reducer(testState, { type: 'INVALID.ACTION' })).toEqual(testState);
});
it('should handle SearchAll.REQUEST', () => {
it('should handle SearchAll.REQUEST', () => {
const term = 'testSearch';
const resource = ResourceType.table;
const pageIndex = 0;
expect(reducer(testState, searchAll(term, resource, pageIndex))).toEqual({
expect(reducer(testState, searchAll(SearchType.SUBMIT_TERM, term, ResourceType.table, 0))).toEqual({
...testState,
inlineResults: initialInlineResultsState,
search_term: term,
......@@ -346,7 +350,7 @@ describe('search ducks', () => {
});
it('should handle SearchResource.REQUEST', () => {
expect(reducer(testState, searchResource('test', ResourceType.table, 0))).toEqual({
expect(reducer(testState, searchResource(SearchType.SUBMIT_TERM, 'test', ResourceType.table, 0))).toEqual({
...initialState,
isLoading: true,
});
......@@ -498,7 +502,7 @@ describe('search ducks', () => {
updateSearchUrlSpy.mockClear();
saga = saga.next().select(SearchUtils.getSearchState).next(mockSearchState);
expect(getPageIndexSpy).toHaveBeenCalledWith(mockSearchState);
saga = saga.put(searchResource(mockSearchState.search_term, mockSearchState.selectedTab, mockIndex)).next();
saga = saga.put(searchResource(SearchType.FILTER, mockSearchState.search_term, mockSearchState.selectedTab, mockIndex)).next();
expect(updateSearchUrlSpy).toHaveBeenCalledWith({
filters: mockSearchState.filters,
resource: mockSearchState.selectedTab,
......@@ -530,7 +534,7 @@ describe('search ducks', () => {
*/
it('handles request error', () => {
testSaga(Sagas.searchAllWorker, searchAll('test', ResourceType.table, 0, true))
testSaga(Sagas.searchAllWorker, searchAll(SearchType.SUBMIT_TERM, 'test', ResourceType.table, 0, true))
.next().select(SearchUtils.getSearchState)
.next(globalState.search).throw(new Error()).put(searchAllFailure())
.next().isDone();
......@@ -551,15 +555,16 @@ describe('search ducks', () => {
const resource = ResourceType.table;
const term = 'test';
const mockSearchState = globalState.search;
testSaga(Sagas.searchResourceWorker, searchResource(term, resource, pageIndex))
const searchType = SearchType.PAGINATION;
testSaga(Sagas.searchResourceWorker, searchResource(searchType, term, resource, pageIndex))
.next().select(SearchUtils.getSearchState)
.next(mockSearchState).call(API.searchResource, pageIndex, resource, term, mockSearchState.filters[resource])
.next(mockSearchState).call(API.searchResource, pageIndex, resource, term, mockSearchState.filters[resource], searchType)
.next(expectedSearchResults).put(searchResourceSuccess(expectedSearchResults))
.next().isDone();
});
it('handles request error', () => {
testSaga(Sagas.searchResourceWorker, searchResource('test', ResourceType.table, 0))
testSaga(Sagas.searchResourceWorker, searchResource(SearchType.PAGINATION, 'test', ResourceType.table, 0))
.next().select(SearchUtils.getSearchState)
.next(globalState.search).throw(new Error()).put(searchResourceFailure())
.next().isDone();
......@@ -573,7 +578,7 @@ describe('search ducks', () => {
updateSearchUrlSpy.mockClear();
testSaga(Sagas.submitSearchWorker, submitSearch(term, true))
.next().select(SearchUtils.getSearchState)
.next(mockSearchState).put(searchAll(term, undefined, undefined, true))
.next(mockSearchState).put(searchAll(SearchType.SUBMIT_TERM, term, undefined, undefined, true))
.next().isDone();
expect(updateSearchUrlSpy).toHaveBeenCalledWith({ term, filters: mockSearchState.filters });
......@@ -632,7 +637,7 @@ describe('search ducks', () => {
testSaga(Sagas.setPageIndexWorker, setPageIndex(index, updateUrl))
.next().select(SearchUtils.getSearchState)
.next(searchState).put(searchResource(searchState.search_term, searchState.selectedTab, index))
.next(searchState).put(searchResource(SearchType.PAGINATION, searchState.search_term, searchState.selectedTab, index))
.next().isDone();
expect(updateSearchUrlSpy).toHaveBeenCalled();
});
......@@ -644,7 +649,7 @@ describe('search ducks', () => {
testSaga(Sagas.setPageIndexWorker, setPageIndex(index, updateUrl))
.next().select(SearchUtils.getSearchState)
.next(searchState).put(searchResource(searchState.search_term, searchState.selectedTab, index))
.next(searchState).put(searchResource(SearchType.PAGINATION, searchState.search_term, searchState.selectedTab, index))
.next().isDone();
expect(updateSearchUrlSpy).not.toHaveBeenCalled();
});
......@@ -679,7 +684,7 @@ describe('search ducks', () => {
it('Calls searchAll when search term changes', () => {
term = 'new search';
sagaTest(urlDidUpdate(`term=${term}&resource=${resource}&index=${index}`))
.put(searchAll(term, resource, index))
.put(searchAll(SearchType.LOAD_URL, term, resource, index))
.next().isDone();
});
......
......@@ -4,6 +4,7 @@ import {
DashboardResource,
Resource,
ResourceType,
SearchType,
TableResource,
UserResource,
} from 'interfaces';
......@@ -53,6 +54,7 @@ export interface SearchAllRequest {
pageIndex: number;
term: string;
useFilters?: boolean;
searchType: SearchType;
};
type: SearchAll.REQUEST;
};
......@@ -75,6 +77,7 @@ export interface SearchResourceRequest {
pageIndex: number;
resource: ResourceType;
term: string;
searchType: SearchType;
};
type: SearchResource.REQUEST;
};
......
......@@ -7,3 +7,13 @@ export enum FilterType {
CHECKBOX_SELECT = 'checkboxFilter',
INPUT_SELECT = 'inputFilter'
}
export enum SearchType {
CLEAR_TERM = 'clear_search_term',
FILTER = 'update_filter',
INLINE_SEARCH = 'inline_search',
INLINE_SELECT = 'inline_select',
LOAD_URL = 'load_url',
PAGINATION = 'update_page',
SUBMIT_TERM = 'submit_search_term',
}
This diff is collapsed.
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