Commit 56c07911 authored by Andrew Fontaine's avatar Andrew Fontaine Committed by Kushal Pandya

Display Saved User Lists by Feature Flags

User Lists can only be created via the API, but this allows users to
view the saved lists and delete them if they are no longer needed.
parent 60ba024c
...@@ -308,10 +308,10 @@ export default { ...@@ -308,10 +308,10 @@ export default {
return axios.put(`${url}/${node.id}`, node); return axios.put(`${url}/${node.id}`, node);
}, },
fetchFeatureFlagUserLists(id) { fetchFeatureFlagUserLists(id, page) {
const url = Api.buildUrl(this.featureFlagUserLists).replace(':id', id); const url = Api.buildUrl(this.featureFlagUserLists).replace(':id', id);
return axios.get(url); return axios.get(url, { params: { page } });
}, },
createFeatureFlagUserList(id, list) { createFeatureFlagUserList(id, list) {
......
...@@ -8,7 +8,9 @@ import { ...@@ -8,7 +8,9 @@ import {
GlModalDirective, GlModalDirective,
GlLink, GlLink,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../constants';
import FeatureFlagsTable from './feature_flags_table.vue'; import FeatureFlagsTable from './feature_flags_table.vue';
import UserListsTable from './user_lists_table.vue';
import store from '../store'; import store from '../store';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
...@@ -27,6 +29,7 @@ export default { ...@@ -27,6 +29,7 @@ export default {
store, store,
components: { components: {
FeatureFlagsTable, FeatureFlagsTable,
UserListsTable,
NavigationTabs, NavigationTabs,
TablePagination, TablePagination,
GlEmptyState, GlEmptyState,
...@@ -43,6 +46,10 @@ export default { ...@@ -43,6 +46,10 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
projectId: {
type: String,
required: true,
},
csrfToken: { csrfToken: {
type: String, type: String,
required: true, required: true,
...@@ -84,18 +91,18 @@ export default { ...@@ -84,18 +91,18 @@ export default {
}, },
data() { data() {
return { return {
scope: getParameterByName('scope') || this.$options.scopes.all, scope: getParameterByName('scope') || this.$options.scopes.featureFlags,
page: getParameterByName('page') || '1', page: getParameterByName('page') || '1',
}; };
}, },
scopes: { scopes: {
all: 'all', [FEATURE_FLAG_SCOPE]: FEATURE_FLAG_SCOPE,
enabled: 'enabled', [USER_LIST_SCOPE]: USER_LIST_SCOPE,
disabled: 'disabled',
}, },
computed: { computed: {
...mapState([ ...mapState([
'featureFlags', FEATURE_FLAG_SCOPE,
USER_LIST_SCOPE,
'count', 'count',
'pageInfo', 'pageInfo',
'isLoading', 'isLoading',
...@@ -108,23 +115,23 @@ export default { ...@@ -108,23 +115,23 @@ export default {
canUserRotateToken() { canUserRotateToken() {
return this.rotateInstanceIdPath !== ''; return this.rotateInstanceIdPath !== '';
}, },
currentlyDisplayedData() {
return this.dataForScope(this.scope);
},
shouldRenderTabs() { shouldRenderTabs() {
/* Do not show tabs until after the first request to get the count */ /* Do not show tabs until after the first request to get the count */
return this.count.all !== undefined; return this.count[this.scope] !== undefined;
}, },
shouldRenderPagination() { shouldRenderPagination() {
return ( return (
!this.isLoading && !this.isLoading &&
!this.hasError && !this.hasError &&
this.featureFlags.length && this.currentlyDisplayedData.length > 0 &&
this.pageInfo.total > this.pageInfo.perPage this.pageInfo[this.scope].total > this.pageInfo[this.scope].perPage
); );
}, },
shouldShowEmptyState() { shouldShowEmptyState() {
return !this.isLoading && !this.hasError && this.featureFlags.length === 0; return !this.isLoading && !this.hasError && this.currentlyDisplayedData.length === 0;
},
shouldRenderTable() {
return !this.isLoading && this.featureFlags.length > 0 && !this.hasError;
}, },
shouldRenderErrorState() { shouldRenderErrorState() {
return this.hasError && !this.isLoading; return this.hasError && !this.isLoading;
...@@ -134,22 +141,16 @@ export default { ...@@ -134,22 +141,16 @@ export default {
return [ return [
{ {
name: __('All'), name: __('Feature Flags'),
scope: scopes.all, scope: scopes[FEATURE_FLAG_SCOPE],
count: this.count.all, count: this.count[FEATURE_FLAG_SCOPE],
isActive: this.scope === scopes.all, isActive: this.scope === scopes[FEATURE_FLAG_SCOPE],
}, },
{ {
name: __('Enabled'), name: __('Lists'),
scope: scopes.enabled, scope: scopes[USER_LIST_SCOPE],
count: this.count.enabled, count: this.count[USER_LIST_SCOPE],
isActive: this.scope === scopes.enabled, isActive: this.scope === scopes[USER_LIST_SCOPE],
},
{
name: __('Disabled'),
scope: scopes.disabled,
count: this.count.disabled,
isActive: this.scope === scopes.disabled,
}, },
]; ];
}, },
...@@ -157,18 +158,15 @@ export default { ...@@ -157,18 +158,15 @@ export default {
return !isEmpty(this.newFeatureFlagPath); return !isEmpty(this.newFeatureFlagPath);
}, },
emptyStateTitle() { emptyStateTitle() {
if (this.scope === this.$options.scopes.disabled) {
return s__(`FeatureFlags|There are no inactive feature flags`);
} else if (this.scope === this.$options.scopes.enabled) {
return s__(`FeatureFlags|There are no active feature flags`);
}
return s__(`FeatureFlags|Get started with feature flags`); return s__(`FeatureFlags|Get started with feature flags`);
}, },
}, },
created() { created() {
this.setFeatureFlagsEndpoint(this.endpoint); this.setFeatureFlagsEndpoint(this.endpoint);
this.setFeatureFlagsOptions({ scope: this.scope, page: this.page }); this.setFeatureFlagsOptions({ scope: this.scope, page: this.page });
this.setProjectId(this.projectId);
this.fetchFeatureFlags(); this.fetchFeatureFlags();
this.fetchUserLists();
this.setInstanceId(this.unleashApiInstanceId); this.setInstanceId(this.unleashApiInstanceId);
this.setInstanceIdEndpoint(this.rotateInstanceIdPath); this.setInstanceIdEndpoint(this.rotateInstanceIdPath);
}, },
...@@ -177,8 +175,10 @@ export default { ...@@ -177,8 +175,10 @@ export default {
'setFeatureFlagsEndpoint', 'setFeatureFlagsEndpoint',
'setFeatureFlagsOptions', 'setFeatureFlagsOptions',
'fetchFeatureFlags', 'fetchFeatureFlags',
'fetchUserLists',
'setInstanceIdEndpoint', 'setInstanceIdEndpoint',
'setInstanceId', 'setInstanceId',
'setProjectId',
'rotateInstanceId', 'rotateInstanceId',
'toggleFeatureFlag', 'toggleFeatureFlag',
]), ]),
...@@ -206,7 +206,22 @@ export default { ...@@ -206,7 +206,22 @@ export default {
historyPushState(buildUrlWithCurrentLocation(`?${queryString}`)); historyPushState(buildUrlWithCurrentLocation(`?${queryString}`));
this.setFeatureFlagsOptions(parameters); this.setFeatureFlagsOptions(parameters);
this.fetchFeatureFlags(); if (this.scope === this.$options.scopes.featureFlags) {
this.fetchFeatureFlags();
} else {
this.fetchUserLists();
}
},
shouldRenderTable(scope) {
return (
!this.isLoading &&
this.dataForScope(scope).length > 0 &&
!this.hasError &&
this.scope === scope
);
},
dataForScope(scope) {
return this[scope];
}, },
}, },
}; };
...@@ -284,12 +299,21 @@ export default { ...@@ -284,12 +299,21 @@ export default {
</gl-empty-state> </gl-empty-state>
<feature-flags-table <feature-flags-table
v-else-if="shouldRenderTable" v-else-if="shouldRenderTable($options.scopes.featureFlags)"
:csrf-token="csrfToken" :csrf-token="csrfToken"
:feature-flags="featureFlags" :feature-flags="featureFlags"
@toggle-flag="toggleFeatureFlag" @toggle-flag="toggleFeatureFlag"
/> />
<table-pagination v-if="shouldRenderPagination" :change="onChangePage" :page-info="pageInfo" /> <user-lists-table
v-else-if="shouldRenderTable($options.scopes.userLists)"
:user-lists="userLists"
/>
<table-pagination
v-if="shouldRenderPagination"
:change="onChangePage"
:page-info="pageInfo[scope]"
/>
</div> </div>
</template> </template>
<script>
import { s__, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { GlTooltipDirective } from '@gitlab/ui';
export default {
directives: { GlTooltip: GlTooltipDirective },
mixins: [timeagoMixin],
props: {
userLists: {
type: Array,
required: true,
},
},
translations: {
createdTimeagoLabel: s__('created %{timeago}'),
},
methods: {
createdTimeago(list) {
return sprintf(this.$options.translations.createdTimeagoLabel, {
timeago: this.timeFormatted(list.created_at),
});
},
displayList(list) {
return list.user_xids.replace(/,/g, ', ');
},
},
};
</script>
<template>
<div>
<div
v-for="list in userLists"
:key="list.id"
data-testid="ffUserList"
class="gl-border-b-solid gl-border-gray-100 gl-border-b-1 gl-w-full gl-py-4 gl-display-flex gl-justify-content-space-between"
>
<div class="gl-display-flex gl-flex-direction-column gl-overflow-hidden gl-flex-grow-1">
<span data-testid="ffUserListName" class="gl-font-weight-bold gl-mb-2">{{
list.name
}}</span>
<span
v-gl-tooltip
:title="tooltipTitle(list.created_at)"
data-testid="ffUserListTimestamp"
class="gl-text-gray-500 gl-mb-2"
>
{{ createdTimeago(list) }}
</span>
<span data-testid="ffUserListIds" class="gl-str-truncated">{{ displayList(list) }}</span>
</div>
</div>
</div>
</template>
...@@ -23,3 +23,6 @@ export const LEGACY_FLAG = 'legacy_flag'; ...@@ -23,3 +23,6 @@ export const LEGACY_FLAG = 'legacy_flag';
export const NEW_FLAG_ALERT = s__( export const NEW_FLAG_ALERT = s__(
'FeatureFlags|Feature Flags will look different in the next milestone. No action is needed, but you may notice the functionality was changed to improve the workflow.', 'FeatureFlags|Feature Flags will look different in the next milestone. No action is needed, but you may notice the functionality was changed to improve the workflow.',
); );
export const FEATURE_FLAG_SCOPE = 'featureFlags';
export const USER_LIST_SCOPE = 'userLists';
...@@ -17,6 +17,7 @@ export default () => ...@@ -17,6 +17,7 @@ export default () =>
return createElement('feature-flags-component', { return createElement('feature-flags-component', {
props: { props: {
endpoint: this.dataset.endpoint, endpoint: this.dataset.endpoint,
projectId: this.dataset.projectId,
errorStateSvgPath: this.dataset.errorStateSvgPath, errorStateSvgPath: this.dataset.errorStateSvgPath,
featureFlagsHelpPagePath: this.dataset.featureFlagsHelpPagePath, featureFlagsHelpPagePath: this.dataset.featureFlagsHelpPagePath,
featureFlagsAnchoredHelpPagePath: this.dataset.featureFlagsAnchoredHelpPagePath, featureFlagsAnchoredHelpPagePath: this.dataset.featureFlagsAnchoredHelpPagePath,
......
import * as types from './mutation_types'; import * as types from './mutation_types';
import Api from 'ee/api';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
export const setFeatureFlagsEndpoint = ({ commit }, endpoint) => export const setFeatureFlagsEndpoint = ({ commit }, endpoint) =>
...@@ -10,6 +11,8 @@ export const setFeatureFlagsOptions = ({ commit }, options) => ...@@ -10,6 +11,8 @@ export const setFeatureFlagsOptions = ({ commit }, options) =>
export const setInstanceIdEndpoint = ({ commit }, endpoint) => export const setInstanceIdEndpoint = ({ commit }, endpoint) =>
commit(types.SET_INSTANCE_ID_ENDPOINT, endpoint); commit(types.SET_INSTANCE_ID_ENDPOINT, endpoint);
export const setProjectId = ({ commit }, endpoint) => commit(types.SET_PROJECT_ID, endpoint);
export const setInstanceId = ({ commit }, instanceId) => commit(types.SET_INSTANCE_ID, instanceId); export const setInstanceId = ({ commit }, instanceId) => commit(types.SET_INSTANCE_ID, instanceId);
export const fetchFeatureFlags = ({ state, dispatch }) => { export const fetchFeatureFlags = ({ state, dispatch }) => {
...@@ -33,6 +36,19 @@ export const receiveFeatureFlagsSuccess = ({ commit }, response) => ...@@ -33,6 +36,19 @@ export const receiveFeatureFlagsSuccess = ({ commit }, response) =>
commit(types.RECEIVE_FEATURE_FLAGS_SUCCESS, response); commit(types.RECEIVE_FEATURE_FLAGS_SUCCESS, response);
export const receiveFeatureFlagsError = ({ commit }) => commit(types.RECEIVE_FEATURE_FLAGS_ERROR); export const receiveFeatureFlagsError = ({ commit }) => commit(types.RECEIVE_FEATURE_FLAGS_ERROR);
export const fetchUserLists = ({ state, dispatch }) => {
dispatch('requestUserLists');
return Api.fetchFeatureFlagUserLists(state.projectId, state.options.page)
.then(({ data, headers }) => dispatch('receiveUserListsSuccess', { data, headers }))
.catch(() => dispatch('receiveUserListsError'));
};
export const requestUserLists = ({ commit }) => commit(types.REQUEST_USER_LISTS);
export const receiveUserListsSuccess = ({ commit }, response) =>
commit(types.RECEIVE_USER_LISTS_SUCCESS, response);
export const receiveUserListsError = ({ commit }) => commit(types.RECEIVE_USER_LISTS_ERROR);
export const toggleFeatureFlag = ({ dispatch }, flag) => { export const toggleFeatureFlag = ({ dispatch }, flag) => {
dispatch('updateFeatureFlag', flag); dispatch('updateFeatureFlag', flag);
......
...@@ -2,11 +2,15 @@ export const SET_FEATURE_FLAGS_ENDPOINT = 'SET_FEATURE_FLAGS_ENDPOINT'; ...@@ -2,11 +2,15 @@ export const SET_FEATURE_FLAGS_ENDPOINT = 'SET_FEATURE_FLAGS_ENDPOINT';
export const SET_FEATURE_FLAGS_OPTIONS = 'SET_FEATURE_FLAGS_OPTIONS'; export const SET_FEATURE_FLAGS_OPTIONS = 'SET_FEATURE_FLAGS_OPTIONS';
export const SET_INSTANCE_ID_ENDPOINT = 'SET_INSTANCE_ID_ENDPOINT'; export const SET_INSTANCE_ID_ENDPOINT = 'SET_INSTANCE_ID_ENDPOINT';
export const SET_INSTANCE_ID = 'SET_INSTANCE_ID'; export const SET_INSTANCE_ID = 'SET_INSTANCE_ID';
export const SET_PROJECT_ID = 'SET_PROJECT_ID';
export const REQUEST_FEATURE_FLAGS = 'REQUEST_FEATURE_FLAGS'; export const REQUEST_FEATURE_FLAGS = 'REQUEST_FEATURE_FLAGS';
export const RECEIVE_FEATURE_FLAGS_SUCCESS = 'RECEIVE_FEATURE_FLAGS_SUCCESS'; export const RECEIVE_FEATURE_FLAGS_SUCCESS = 'RECEIVE_FEATURE_FLAGS_SUCCESS';
export const RECEIVE_FEATURE_FLAGS_ERROR = 'RECEIVE_FEATURE_FLAGS_ERROR'; export const RECEIVE_FEATURE_FLAGS_ERROR = 'RECEIVE_FEATURE_FLAGS_ERROR';
export const REQUEST_USER_LISTS = 'REQUEST_USER_LISTS';
export const RECEIVE_USER_LISTS_SUCCESS = 'RECEIVE_USER_LISTS_SUCCESS';
export const RECEIVE_USER_LISTS_ERROR = 'RECEIVE_USER_LISTS_ERROR';
export const UPDATE_FEATURE_FLAG = 'UPDATE_FEATURE_FLAG'; export const UPDATE_FEATURE_FLAG = 'UPDATE_FEATURE_FLAG';
export const RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS = 'RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS'; export const RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS = 'RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS';
export const RECEIVE_UPDATE_FEATURE_FLAG_ERROR = 'RECEIVE_UPDATE_FEATURE_FLAG_ERROR'; export const RECEIVE_UPDATE_FEATURE_FLAG_ERROR = 'RECEIVE_UPDATE_FEATURE_FLAG_ERROR';
......
import Vue from 'vue'; import Vue from 'vue';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../../../constants';
import { mapToScopesViewModel } from '../helpers'; import { mapToScopesViewModel } from '../helpers';
const mapFlag = flag => ({ ...flag, scopes: mapToScopesViewModel(flag.scopes || []) }); const mapFlag = flag => ({ ...flag, scopes: mapToScopesViewModel(flag.scopes || []) });
const updateFlag = (state, flag) => { const updateFlag = (state, flag) => {
const i = state.featureFlags.findIndex(({ id }) => id === flag.id); const index = state[FEATURE_FLAG_SCOPE].findIndex(({ id }) => id === flag.id);
const staleFlag = state.featureFlags.find(({ id }) => id === flag.id); Vue.set(state[FEATURE_FLAG_SCOPE], index, flag);
Vue.set(state.featureFlags, i, flag); };
if (staleFlag.active !== flag.active) { const createPaginationInfo = (state, headers) => {
const change = flag.active ? 1 : -1; let paginationInfo;
Vue.set(state.count, 'enabled', state.count.enabled + change); if (Object.keys(headers).length) {
Vue.set(state.count, 'disabled', state.count.disabled - change); const normalizedHeaders = normalizeHeaders(headers);
paginationInfo = parseIntPagination(normalizedHeaders);
} else {
paginationInfo = headers;
} }
return paginationInfo;
}; };
export default { export default {
...@@ -30,28 +35,53 @@ export default { ...@@ -30,28 +35,53 @@ export default {
[types.SET_INSTANCE_ID](state, instance) { [types.SET_INSTANCE_ID](state, instance) {
state.instanceId = instance; state.instanceId = instance;
}, },
[types.SET_PROJECT_ID](state, project) {
state.projectId = project;
},
[types.REQUEST_FEATURE_FLAGS](state) { [types.REQUEST_FEATURE_FLAGS](state) {
state.isLoading = true; state.isLoading = true;
}, },
[types.RECEIVE_FEATURE_FLAGS_SUCCESS](state, response) { [types.RECEIVE_FEATURE_FLAGS_SUCCESS](state, response) {
state.isLoading = false; state.isLoading = false;
state.hasError = false; state.hasError = false;
state.count = response.data.count; state[FEATURE_FLAG_SCOPE] = (response.data.feature_flags || []).map(mapFlag);
state.featureFlags = (response.data.feature_flags || []).map(mapFlag);
let paginationInfo; const paginationInfo = createPaginationInfo(state, response.headers);
if (Object.keys(response.headers).length) { state.count = {
const normalizedHeaders = normalizeHeaders(response.headers); ...state.count,
paginationInfo = parseIntPagination(normalizedHeaders); [FEATURE_FLAG_SCOPE]: paginationInfo?.total ?? state[FEATURE_FLAG_SCOPE].length,
} else { };
paginationInfo = response.headers; state.pageInfo = {
} ...state.pageInfo,
state.pageInfo = paginationInfo; [FEATURE_FLAG_SCOPE]: paginationInfo,
};
}, },
[types.RECEIVE_FEATURE_FLAGS_ERROR](state) { [types.RECEIVE_FEATURE_FLAGS_ERROR](state) {
state.isLoading = false; state.isLoading = false;
state.hasError = true; state.hasError = true;
}, },
[types.REQUEST_USER_LISTS](state) {
state.isLoading = true;
},
[types.RECEIVE_USER_LISTS_SUCCESS](state, response) {
state.isLoading = false;
state.hasError = false;
state[USER_LIST_SCOPE] = response.data || [];
const paginationInfo = createPaginationInfo(state, response.headers);
state.count = {
...state.count,
[USER_LIST_SCOPE]: paginationInfo?.total ?? state[USER_LIST_SCOPE].length,
};
state.pageInfo = {
...state.pageInfo,
[USER_LIST_SCOPE]: paginationInfo,
};
},
[types.RECEIVE_USER_LISTS_ERROR](state) {
state.isLoading = false;
state.hasError = true;
},
[types.REQUEST_ROTATE_INSTANCE_ID](state) { [types.REQUEST_ROTATE_INSTANCE_ID](state) {
state.isRotating = true; state.isRotating = true;
state.hasRotateError = false; state.hasRotateError = false;
...@@ -77,7 +107,7 @@ export default { ...@@ -77,7 +107,7 @@ export default {
updateFlag(state, mapFlag(data)); updateFlag(state, mapFlag(data));
}, },
[types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](state, i) { [types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](state, i) {
const flag = state.featureFlags.find(({ id }) => i === id); const flag = state[FEATURE_FLAG_SCOPE].find(({ id }) => i === id);
updateFlag(state, { ...flag, active: !flag.active }); updateFlag(state, { ...flag, active: !flag.active });
}, },
}; };
import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../../../constants';
export default () => ({ export default () => ({
featureFlags: [], [FEATURE_FLAG_SCOPE]: [],
[USER_LIST_SCOPE]: [],
count: {}, count: {},
pageInfo: {}, pageInfo: { [FEATURE_FLAG_SCOPE]: {}, [USER_LIST_SCOPE]: {} },
isLoading: true, isLoading: true,
hasError: false, hasError: false,
endpoint: null, endpoint: null,
...@@ -10,4 +13,5 @@ export default () => ({ ...@@ -10,4 +13,5 @@ export default () => ({
isRotating: false, isRotating: false,
hasRotateError: false, hasRotateError: false,
options: {}, options: {},
projectId: '',
}); });
- page_title s_('FeatureFlags|Feature Flags') - page_title s_('FeatureFlags|Feature Flags')
#feature-flags-vue{ data: { endpoint: project_feature_flags_path(@project, format: :json), #feature-flags-vue{ data: { endpoint: project_feature_flags_path(@project, format: :json),
"project-id" => @project.id,
"error-state-svg-path" => image_path('illustrations/feature_flag.svg'), "error-state-svg-path" => image_path('illustrations/feature_flag.svg'),
"feature-flags-help-page-path" => help_page_path("user/project/operations/feature_flags"), "feature-flags-help-page-path" => help_page_path("user/project/operations/feature_flags"),
"feature-flags-anchored-help-page-path" => help_page_path("user/project/operations/feature_flags", anchor: "client-libraries"), "feature-flags-anchored-help-page-path" => help_page_path("user/project/operations/feature_flags", anchor: "client-libraries"),
......
---
title: Display Saved User Lists by Feature Flags
merge_request: 34294
author:
type: added
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import Api from 'ee/api';
import store from 'ee/feature_flags/store'; import store from 'ee/feature_flags/store';
import FeatureFlagsComponent from 'ee/feature_flags/components/feature_flags.vue'; import FeatureFlagsComponent from 'ee/feature_flags/components/feature_flags.vue';
import FeatureFlagsTable from 'ee/feature_flags/components/feature_flags_table.vue'; import FeatureFlagsTable from 'ee/feature_flags/components/feature_flags_table.vue';
import UserListsTable from 'ee/feature_flags/components/user_lists_table.vue';
import ConfigureFeatureFlagsModal from 'ee/feature_flags/components/configure_feature_flags_modal.vue'; import ConfigureFeatureFlagsModal from 'ee/feature_flags/components/configure_feature_flags_modal.vue';
import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from 'ee/feature_flags/constants';
import { TEST_HOST } from 'spec/test_constants'; import { TEST_HOST } from 'spec/test_constants';
import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { getRequestData } from '../mock_data'; import { getRequestData, userList } from '../mock_data';
describe('Feature flags', () => { describe('Feature flags', () => {
const mockData = { const mockData = {
...@@ -23,6 +26,7 @@ describe('Feature flags', () => { ...@@ -23,6 +26,7 @@ describe('Feature flags', () => {
canUserConfigure: true, canUserConfigure: true,
canUserRotateToken: true, canUserRotateToken: true,
newFeatureFlagPath: 'feature-flags/new', newFeatureFlagPath: 'feature-flags/new',
projectId: '8',
}; };
let wrapper; let wrapper;
...@@ -40,6 +44,17 @@ describe('Feature flags', () => { ...@@ -40,6 +44,17 @@ describe('Feature flags', () => {
beforeEach(() => { beforeEach(() => {
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
jest.spyOn(store, 'dispatch'); jest.spyOn(store, 'dispatch');
jest.spyOn(Api, 'fetchFeatureFlagUserLists').mockResolvedValue({
data: [userList],
headers: {
'x-next-page': '2',
'x-page': '1',
'X-Per-Page': '8',
'X-Prev-Page': '',
'X-TOTAL': '40',
'X-Total-Pages': '5',
},
});
}); });
afterEach(() => { afterEach(() => {
...@@ -58,11 +73,12 @@ describe('Feature flags', () => { ...@@ -58,11 +73,12 @@ describe('Feature flags', () => {
featureFlagsAnchoredHelpPagePath: '/help/feature-flags#unleash-clients', featureFlagsAnchoredHelpPagePath: '/help/feature-flags#unleash-clients',
unleashApiUrl: `${TEST_HOST}/api/unleash`, unleashApiUrl: `${TEST_HOST}/api/unleash`,
unleashApiInstanceId: 'oP6sCNRqtRHmpy1gw2-F', unleashApiInstanceId: 'oP6sCNRqtRHmpy1gw2-F',
projectId: '8',
}; };
beforeEach(done => { beforeEach(done => {
mock mock
.onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: 'all', page: '1' } }) .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
.reply(200, getRequestData, {}); .reply(200, getRequestData, {});
factory(propsData); factory(propsData);
...@@ -84,7 +100,7 @@ describe('Feature flags', () => { ...@@ -84,7 +100,7 @@ describe('Feature flags', () => {
describe('loading state', () => { describe('loading state', () => {
it('renders a loading icon', () => { it('renders a loading icon', () => {
mock mock
.onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: 'all', page: '1' } }) .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
.replyOnce(200, getRequestData, {}); .replyOnce(200, getRequestData, {});
factory(); factory();
...@@ -101,18 +117,20 @@ describe('Feature flags', () => { ...@@ -101,18 +117,20 @@ describe('Feature flags', () => {
let emptyState; let emptyState;
beforeEach(done => { beforeEach(done => {
mock.onGet(mockData.endpoint, { params: { scope: 'all', page: '1' } }).replyOnce( mock
200, .onGet(mockData.endpoint, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
{ .replyOnce(
feature_flags: [], 200,
count: { {
all: 0, feature_flags: [],
enabled: 0, count: {
disabled: 0, all: 0,
enabled: 0,
disabled: 0,
},
}, },
}, {},
{}, );
);
factory(); factory();
...@@ -134,37 +152,17 @@ describe('Feature flags', () => { ...@@ -134,37 +152,17 @@ describe('Feature flags', () => {
expect(newButton().exists()).toBe(true); expect(newButton().exists()).toBe(true);
}); });
describe('in all tab', () => { describe('in feature flags tab', () => {
it('renders generic title', () => { it('renders generic title', () => {
expect(emptyState.props('title')).toEqual('Get started with feature flags'); expect(emptyState.props('title')).toEqual('Get started with feature flags');
}); });
}); });
describe('in disabled tab', () => {
it('renders disabled title', () => {
wrapper.setData({ scope: 'disabled' });
return wrapper.vm.$nextTick(() => {
expect(emptyState.props('title')).toEqual('There are no inactive feature flags');
});
});
});
describe('in enabled tab', () => {
it('renders enabled title', () => {
wrapper.setData({ scope: 'enabled' });
wrapper.vm.$nextTick(() => {
expect(emptyState.props('title')).toEqual('There are no active feature flags');
});
});
});
}); });
describe('with paginated feature flags', () => { describe('with paginated feature flags', () => {
beforeEach(done => { beforeEach(done => {
mock mock
.onGet(mockData.endpoint, { params: { scope: 'all', page: '1' } }) .onGet(mockData.endpoint, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
.replyOnce(200, getRequestData, { .replyOnce(200, getRequestData, {
'x-next-page': '2', 'x-next-page': '2',
'x-page': '1', 'x-page': '1',
...@@ -183,7 +181,7 @@ describe('Feature flags', () => { ...@@ -183,7 +181,7 @@ describe('Feature flags', () => {
it('should render a table with feature flags', () => { it('should render a table with feature flags', () => {
const table = wrapper.find(FeatureFlagsTable); const table = wrapper.find(FeatureFlagsTable);
expect(table.exists()).toBe(true); expect(table.exists()).toBe(true);
expect(table.props('featureFlags')).toEqual( expect(table.props(FEATURE_FLAG_SCOPE)).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
name: getRequestData.feature_flags[0].name, name: getRequestData.feature_flags[0].name,
...@@ -196,7 +194,7 @@ describe('Feature flags', () => { ...@@ -196,7 +194,7 @@ describe('Feature flags', () => {
it('should toggle a flag when receiving the toggle-flag event', () => { it('should toggle a flag when receiving the toggle-flag event', () => {
const table = wrapper.find(FeatureFlagsTable); const table = wrapper.find(FeatureFlagsTable);
const [flag] = table.props('featureFlags'); const [flag] = table.props(FEATURE_FLAG_SCOPE);
table.vm.$emit('toggle-flag', flag); table.vm.$emit('toggle-flag', flag);
expect(store.dispatch).toHaveBeenCalledWith('index/toggleFeatureFlag', flag); expect(store.dispatch).toHaveBeenCalledWith('index/toggleFeatureFlag', flag);
...@@ -220,27 +218,52 @@ describe('Feature flags', () => { ...@@ -220,27 +218,52 @@ describe('Feature flags', () => {
wrapper.find(TablePagination).vm.change(4); wrapper.find(TablePagination).vm.change(4);
expect(wrapper.vm.updateFeatureFlagOptions).toHaveBeenCalledWith({ expect(wrapper.vm.updateFeatureFlagOptions).toHaveBeenCalledWith({
scope: 'all', scope: FEATURE_FLAG_SCOPE,
page: '4', page: '4',
}); });
}); });
it('should make an API request when using tabs', () => { it('should make an API request when using tabs', () => {
jest.spyOn(wrapper.vm, 'updateFeatureFlagOptions'); jest.spyOn(wrapper.vm, 'updateFeatureFlagOptions');
wrapper.find(NavigationTabs).vm.$emit('onChangeTab', 'enabled'); wrapper.find(NavigationTabs).vm.$emit('onChangeTab', USER_LIST_SCOPE);
expect(wrapper.vm.updateFeatureFlagOptions).toHaveBeenCalledWith({ expect(wrapper.vm.updateFeatureFlagOptions).toHaveBeenCalledWith({
scope: 'enabled', scope: USER_LIST_SCOPE,
page: '1', page: '1',
}); });
}); });
}); });
}); });
describe('in user lists tab', () => {
beforeEach(done => {
factory();
setImmediate(() => {
done();
});
});
beforeEach(() => {
wrapper.find(NavigationTabs).vm.$emit('onChangeTab', USER_LIST_SCOPE);
return wrapper.vm.$nextTick();
});
it('should display the user list table', () => {
expect(wrapper.contains(UserListsTable)).toBe(true);
});
it('should set the user lists to display', () => {
expect(wrapper.find(UserListsTable).props('userLists')).toEqual([userList]);
});
});
}); });
describe('unsuccessful request', () => { describe('unsuccessful request', () => {
beforeEach(done => { beforeEach(done => {
mock.onGet(mockData.endpoint, { params: { scope: 'all', page: '1' } }).replyOnce(500, {}); mock
.onGet(mockData.endpoint, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
.replyOnce(500, {});
Api.fetchFeatureFlagUserLists.mockRejectedValueOnce();
factory(); factory();
...@@ -269,7 +292,7 @@ describe('Feature flags', () => { ...@@ -269,7 +292,7 @@ describe('Feature flags', () => {
describe('rotate instance id', () => { describe('rotate instance id', () => {
beforeEach(done => { beforeEach(done => {
mock mock
.onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: 'all', page: '1' } }) .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } })
.reply(200, getRequestData, {}); .reply(200, getRequestData, {});
factory(); factory();
......
import { mount } from '@vue/test-utils';
import * as timeago from 'timeago.js';
import UserListsTable from 'ee/feature_flags/components/user_lists_table.vue';
import { userList } from '../mock_data';
jest.mock('timeago.js', () => ({
format: jest.fn().mockReturnValue('2 weeks ago'),
register: jest.fn(),
}));
describe('User Lists Table', () => {
let wrapper;
let userLists;
beforeEach(() => {
userLists = new Array(5).fill(userList).map((x, i) => ({ ...x, id: i }));
wrapper = mount(UserListsTable, {
propsData: { userLists },
});
});
afterEach(() => {
wrapper.destroy();
});
it('should display the details of a user list', () => {
expect(wrapper.find('[data-testid="ffUserListName"]').text()).toBe(userList.name);
expect(wrapper.find('[data-testid="ffUserListIds"]').text()).toBe(
userList.user_xids.replace(/,/g, ', '),
);
expect(wrapper.find('[data-testid="ffUserListTimestamp"]').text()).toBe('created 2 weeks ago');
expect(timeago.format).toHaveBeenCalledWith(userList.created_at);
});
it('should set the title for a tooltip on the created stamp', () => {
expect(wrapper.find('[data-testid="ffUserListTimestamp"]').attributes('title')).toBe(
'Feb 4, 2020 8:13am GMT+0000',
);
});
it('should display a user list entry per user list', () => {
const lists = wrapper.findAll('[data-testid="ffUserList"]');
expect(lists).toHaveLength(5);
lists.wrappers.forEach(list => {
expect(list.contains('[data-testid="ffUserListName"]')).toBe(true);
expect(list.contains('[data-testid="ffUserListIds"]')).toBe(true);
expect(list.contains('[data-testid="ffUserListTimestamp"]')).toBe(true);
});
});
});
...@@ -16,6 +16,10 @@ import { ...@@ -16,6 +16,10 @@ import {
updateFeatureFlag, updateFeatureFlag,
receiveUpdateFeatureFlagSuccess, receiveUpdateFeatureFlagSuccess,
receiveUpdateFeatureFlagError, receiveUpdateFeatureFlagError,
requestUserLists,
receiveUserListsSuccess,
receiveUserListsError,
fetchUserLists,
} from 'ee/feature_flags/store/modules/index/actions'; } from 'ee/feature_flags/store/modules/index/actions';
import { mapToScopesViewModel } from 'ee/feature_flags/store/modules/helpers'; import { mapToScopesViewModel } from 'ee/feature_flags/store/modules/helpers';
import state from 'ee/feature_flags/store/modules/index/state'; import state from 'ee/feature_flags/store/modules/index/state';
...@@ -23,7 +27,10 @@ import * as types from 'ee/feature_flags/store/modules/index/mutation_types'; ...@@ -23,7 +27,10 @@ import * as types from 'ee/feature_flags/store/modules/index/mutation_types';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import { TEST_HOST } from 'spec/test_constants'; import { TEST_HOST } from 'spec/test_constants';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { getRequestData, rotateData, featureFlag } from '../../mock_data'; import Api from 'ee/api';
import { getRequestData, rotateData, featureFlag, userList } from '../../mock_data';
jest.mock('ee/api.js');
describe('Feature flags actions', () => { describe('Feature flags actions', () => {
let mockedState; let mockedState;
...@@ -186,6 +193,99 @@ describe('Feature flags actions', () => { ...@@ -186,6 +193,99 @@ describe('Feature flags actions', () => {
}); });
}); });
describe('fetchUserLists', () => {
beforeEach(() => {
Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [userList], headers: {} });
});
describe('success', () => {
it('dispatches requestUserLists and receiveUserListsSuccess ', done => {
testAction(
fetchUserLists,
null,
mockedState,
[],
[
{
type: 'requestUserLists',
},
{
payload: { data: [userList], headers: {} },
type: 'receiveUserListsSuccess',
},
],
done,
);
});
});
describe('error', () => {
it('dispatches requestUserLists and receiveUserListsError ', done => {
Api.fetchFeatureFlagUserLists.mockRejectedValue();
testAction(
fetchUserLists,
null,
mockedState,
[],
[
{
type: 'requestUserLists',
},
{
type: 'receiveUserListsError',
},
],
done,
);
});
});
});
describe('requestUserLists', () => {
it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', done => {
testAction(
requestUserLists,
null,
mockedState,
[{ type: types.REQUEST_USER_LISTS }],
[],
done,
);
});
});
describe('receiveUserListsSuccess', () => {
it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', done => {
testAction(
receiveUserListsSuccess,
{ data: [userList], headers: {} },
mockedState,
[
{
type: types.RECEIVE_USER_LISTS_SUCCESS,
payload: { data: [userList], headers: {} },
},
],
[],
done,
);
});
});
describe('receiveUserListsError', () => {
it('should commit RECEIVE_USER_LISTS_ERROR mutation', done => {
testAction(
receiveUserListsError,
null,
mockedState,
[{ type: types.RECEIVE_USER_LISTS_ERROR }],
[],
done,
);
});
});
describe('rotateInstanceId', () => { describe('rotateInstanceId', () => {
let mock; let mock;
......
...@@ -3,7 +3,7 @@ import mutations from 'ee/feature_flags/store/modules/index/mutations'; ...@@ -3,7 +3,7 @@ import mutations from 'ee/feature_flags/store/modules/index/mutations';
import * as types from 'ee/feature_flags/store/modules/index/mutation_types'; import * as types from 'ee/feature_flags/store/modules/index/mutation_types';
import { mapToScopesViewModel } from 'ee/feature_flags/store/modules/helpers'; import { mapToScopesViewModel } from 'ee/feature_flags/store/modules/helpers';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { getRequestData, rotateData, featureFlag } from '../../mock_data'; import { getRequestData, rotateData, featureFlag, userList } from '../../mock_data';
describe('Feature flags store Mutations', () => { describe('Feature flags store Mutations', () => {
let stateCopy; let stateCopy;
...@@ -84,11 +84,13 @@ describe('Feature flags store Mutations', () => { ...@@ -84,11 +84,13 @@ describe('Feature flags store Mutations', () => {
}); });
it('should set count with the given data', () => { it('should set count with the given data', () => {
expect(stateCopy.count).toEqual(getRequestData.count); expect(stateCopy.count.featureFlags).toEqual(37);
}); });
it('should set pagination', () => { it('should set pagination', () => {
expect(stateCopy.pageInfo).toEqual(parseIntPagination(normalizeHeaders(headers))); expect(stateCopy.pageInfo.featureFlags).toEqual(
parseIntPagination(normalizeHeaders(headers)),
);
}); });
}); });
...@@ -106,6 +108,58 @@ describe('Feature flags store Mutations', () => { ...@@ -106,6 +108,58 @@ describe('Feature flags store Mutations', () => {
}); });
}); });
describe('REQUEST_USER_LISTS', () => {
it('sets isLoading to true', () => {
mutations[types.REQUEST_USER_LISTS](stateCopy);
expect(stateCopy.isLoading).toBe(true);
});
});
describe('RECIEVE_USER_LISTS_SUCCESS', () => {
const headers = {
'x-next-page': '2',
'x-page': '1',
'X-Per-Page': '2',
'X-Prev-Page': '',
'X-TOTAL': '37',
'X-Total-Pages': '5',
};
beforeEach(() => {
mutations[types.RECEIVE_USER_LISTS_SUCCESS](stateCopy, { data: [userList], headers });
});
it('sets isLoading to false', () => {
expect(stateCopy.isLoading).toBe(false);
});
it('sets userLists to the received userLists', () => {
expect(stateCopy.userLists).toEqual([userList]);
});
it('sets pagination info for user lits', () => {
expect(stateCopy.pageInfo.userLists).toEqual(parseIntPagination(normalizeHeaders(headers)));
});
it('sets the count for user lists', () => {
expect(stateCopy.count.userLists).toBe(parseInt(headers['X-TOTAL'], 10));
});
});
describe('RECEIVE_USER_LISTS_ERROR', () => {
beforeEach(() => {
mutations[types.RECEIVE_USER_LISTS_ERROR](stateCopy);
});
it('should set isLoading to false', () => {
expect(stateCopy.isLoading).toEqual(false);
});
it('should set hasError to true', () => {
expect(stateCopy.hasError).toEqual(true);
});
});
describe('REQUEST_ROTATE_INSTANCE_ID', () => { describe('REQUEST_ROTATE_INSTANCE_ID', () => {
beforeEach(() => { beforeEach(() => {
mutations[types.REQUEST_ROTATE_INSTANCE_ID](stateCopy); mutations[types.REQUEST_ROTATE_INSTANCE_ID](stateCopy);
...@@ -158,7 +212,7 @@ describe('Feature flags store Mutations', () => { ...@@ -158,7 +212,7 @@ describe('Feature flags store Mutations', () => {
...flag, ...flag,
scopes: mapToScopesViewModel(flag.scopes || []), scopes: mapToScopesViewModel(flag.scopes || []),
})); }));
stateCopy.count = { enabled: 1, disabled: 0 }; stateCopy.count = { featureFlags: 1, userLists: 0 };
mutations[types.UPDATE_FEATURE_FLAG](stateCopy, { mutations[types.UPDATE_FEATURE_FLAG](stateCopy, {
...featureFlag, ...featureFlag,
...@@ -176,12 +230,6 @@ describe('Feature flags store Mutations', () => { ...@@ -176,12 +230,6 @@ describe('Feature flags store Mutations', () => {
}, },
]); ]);
}); });
it('should update the enabled count', () => {
expect(stateCopy.count.enabled).toBe(0);
});
it('should update the disabled count', () => {
expect(stateCopy.count.disabled).toBe(1);
});
}); });
describe('RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS', () => { describe('RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS', () => {
...@@ -191,7 +239,7 @@ describe('Feature flags store Mutations', () => { ...@@ -191,7 +239,7 @@ describe('Feature flags store Mutations', () => {
...flagState, ...flagState,
scopes: mapToScopesViewModel(flag.scopes || []), scopes: mapToScopesViewModel(flag.scopes || []),
})); }));
stateCopy.count = stateCount; stateCopy.count.featureFlags = stateCount;
mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](stateCopy, { mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](stateCopy, {
...featureFlag, ...featureFlag,
...@@ -210,32 +258,6 @@ describe('Feature flags store Mutations', () => { ...@@ -210,32 +258,6 @@ describe('Feature flags store Mutations', () => {
}, },
]); ]);
}); });
it('updates the count data', () => {
runUpdate({ all: 1, enabled: 1, disabled: 0 }, { active: true }, { active: false });
expect(stateCopy.count).toEqual({ all: 1, enabled: 0, disabled: 1 });
});
describe('when count data does not match up with the number of flags in state', () => {
it('updates the count data when the flag changes to inactive', () => {
runUpdate({ all: 4, enabled: 1, disabled: 3 }, { active: true }, { active: false });
expect(stateCopy.count).toEqual({ all: 4, enabled: 0, disabled: 4 });
});
it('updates the count data when the flag changes to active', () => {
runUpdate({ all: 4, enabled: 1, disabled: 3 }, { active: false }, { active: true });
expect(stateCopy.count).toEqual({ all: 4, enabled: 2, disabled: 2 });
});
it('retains the count data when flag.active does not change', () => {
runUpdate({ all: 4, enabled: 1, disabled: 3 }, { active: true }, { active: true });
expect(stateCopy.count).toEqual({ all: 4, enabled: 1, disabled: 3 });
});
});
}); });
describe('RECEIVE_UPDATE_FEATURE_FLAG_ERROR', () => { describe('RECEIVE_UPDATE_FEATURE_FLAG_ERROR', () => {
...@@ -258,11 +280,5 @@ describe('Feature flags store Mutations', () => { ...@@ -258,11 +280,5 @@ describe('Feature flags store Mutations', () => {
}, },
]); ]);
}); });
it('should update the enabled count', () => {
expect(stateCopy.count.enabled).toBe(0);
});
it('should update the disabled count', () => {
expect(stateCopy.count.disabled).toBe(1);
});
}); });
}); });
...@@ -70,8 +70,7 @@ module FeatureFlagHelpers ...@@ -70,8 +70,7 @@ module FeatureFlagHelpers
def expect_user_to_see_feature_flags_index_page def expect_user_to_see_feature_flags_index_page
expect(page).to have_css('h3.page-title', text: 'Feature Flags') expect(page).to have_css('h3.page-title', text: 'Feature Flags')
expect(page).to have_text('All') expect(page).to have_text('Feature Flags')
expect(page).to have_text('Enabled') expect(page).to have_text('Lists')
expect(page).to have_text('Disabled')
end end
end end
...@@ -9683,12 +9683,6 @@ msgstr "" ...@@ -9683,12 +9683,6 @@ msgstr ""
msgid "FeatureFlags|Target environments" msgid "FeatureFlags|Target environments"
msgstr "" msgstr ""
msgid "FeatureFlags|There are no active feature flags"
msgstr ""
msgid "FeatureFlags|There are no inactive feature flags"
msgstr ""
msgid "FeatureFlags|There was an error fetching the feature flags." msgid "FeatureFlags|There was an error fetching the feature flags."
msgstr "" msgstr ""
...@@ -13274,6 +13268,9 @@ msgstr "" ...@@ -13274,6 +13268,9 @@ msgstr ""
msgid "List your Bitbucket Server repositories" msgid "List your Bitbucket Server repositories"
msgstr "" msgstr ""
msgid "Lists"
msgstr ""
msgid "Live preview" msgid "Live preview"
msgstr "" msgstr ""
...@@ -26584,6 +26581,9 @@ msgstr "" ...@@ -26584,6 +26581,9 @@ msgstr ""
msgid "created %{timeAgo}" msgid "created %{timeAgo}"
msgstr "" msgstr ""
msgid "created %{timeago}"
msgstr ""
msgid "customize" msgid "customize"
msgstr "" 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