summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/user_lists
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/user_lists')
-rw-r--r--app/assets/javascripts/user_lists/components/user_list_form.vue2
-rw-r--r--app/assets/javascripts/user_lists/components/user_lists.vue120
-rw-r--r--app/assets/javascripts/user_lists/components/user_lists_table.vue125
-rw-r--r--app/assets/javascripts/user_lists/store/index/actions.js38
-rw-r--r--app/assets/javascripts/user_lists/store/index/index.js11
-rw-r--r--app/assets/javascripts/user_lists/store/index/mutation_types.js10
-rw-r--r--app/assets/javascripts/user_lists/store/index/mutations.js37
-rw-r--r--app/assets/javascripts/user_lists/store/index/state.js10
8 files changed, 352 insertions, 1 deletions
diff --git a/app/assets/javascripts/user_lists/components/user_list_form.vue b/app/assets/javascripts/user_lists/components/user_list_form.vue
index a0364089d68..b53aaf46ace 100644
--- a/app/assets/javascripts/user_lists/components/user_list_form.vue
+++ b/app/assets/javascripts/user_lists/components/user_list_form.vue
@@ -75,7 +75,7 @@ export default {
</template>
</gl-sprintf>
</div>
- <div class="gl-flex-fill-1 gl-ml-7">
+ <div class="gl-flex-grow-1 gl-ml-7">
<gl-form-group
label-for="user-list-name"
:label="$options.translations.nameLabel"
diff --git a/app/assets/javascripts/user_lists/components/user_lists.vue b/app/assets/javascripts/user_lists/components/user_lists.vue
new file mode 100644
index 00000000000..80be894c689
--- /dev/null
+++ b/app/assets/javascripts/user_lists/components/user_lists.vue
@@ -0,0 +1,120 @@
+<script>
+import { GlBadge, GlButton } from '@gitlab/ui';
+import { isEmpty } from 'lodash';
+import { mapState, mapActions } from 'vuex';
+import EmptyState from '~/feature_flags/components/empty_state.vue';
+import {
+ buildUrlWithCurrentLocation,
+ getParameterByName,
+ historyPushState,
+} from '~/lib/utils/common_utils';
+import { objectToQuery } from '~/lib/utils/url_utility';
+import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
+import UserListsTable from './user_lists_table.vue';
+
+export default {
+ components: {
+ EmptyState,
+ UserListsTable,
+ GlBadge,
+ GlButton,
+ TablePagination,
+ },
+ inject: {
+ newUserListPath: { default: '' },
+ },
+ data() {
+ return {
+ page: getParameterByName('page') || '1',
+ };
+ },
+ computed: {
+ ...mapState(['userLists', 'alerts', 'count', 'pageInfo', 'isLoading', 'hasError', 'options']),
+ canUserRotateToken() {
+ return this.rotateInstanceIdPath !== '';
+ },
+ shouldRenderPagination() {
+ return (
+ !this.isLoading &&
+ !this.hasError &&
+ this.userLists.length > 0 &&
+ this.pageInfo.total > this.pageInfo.perPage
+ );
+ },
+ shouldShowEmptyState() {
+ return !this.isLoading && !this.hasError && this.userLists.length === 0;
+ },
+ shouldRenderErrorState() {
+ return this.hasError && !this.isLoading;
+ },
+ shouldRenderUserLists() {
+ return !this.isLoading && this.userLists.length > 0 && !this.hasError;
+ },
+ hasNewPath() {
+ return !isEmpty(this.newUserListPath);
+ },
+ },
+ created() {
+ this.setUserListsOptions({ page: this.page });
+ this.fetchUserLists();
+ },
+ methods: {
+ ...mapActions(['setUserListsOptions', 'fetchUserLists', 'clearAlert', 'deleteUserList']),
+ onChangePage(page) {
+ this.updateUserListsOptions({
+ /* URLS parameters are strings, we need to parse to match types */
+ page: Number(page).toString(),
+ });
+ },
+ updateUserListsOptions(parameters) {
+ const queryString = objectToQuery(parameters);
+
+ historyPushState(buildUrlWithCurrentLocation(`?${queryString}`));
+ this.setUserListsOptions(parameters);
+ this.fetchUserLists();
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div class="gl-display-flex gl-flex-direction-column">
+ <div class="gl-display-flex gl-flex-direction-column gl-md-display-none!">
+ <gl-button v-if="hasNewPath" :href="newUserListPath" variant="confirm">
+ {{ s__('UserLists|New user list') }}
+ </gl-button>
+ </div>
+ <div
+ class="gl-display-flex gl-align-items-baseline gl-flex-direction-column gl-md-flex-direction-row gl-justify-content-space-between gl-mt-6"
+ >
+ <div class="gl-display-flex gl-align-items-center">
+ <h2 class="gl-font-size-h2 gl-my-0">
+ {{ s__('UserLists|User Lists') }}
+ </h2>
+ <gl-badge v-if="count" class="gl-ml-4">{{ count }}</gl-badge>
+ </div>
+ <div class="gl-display-flex gl-align-items-center gl-justify-content-end">
+ <gl-button v-if="hasNewPath" :href="newUserListPath" variant="confirm">
+ {{ s__('UserLists|New user list') }}
+ </gl-button>
+ </div>
+ </div>
+ <empty-state
+ :alerts="alerts"
+ :is-loading="isLoading"
+ :loading-label="s__('UserLists|Loading user lists')"
+ :error-state="shouldRenderErrorState"
+ :error-title="s__('UserLists|There was an error fetching the user lists.')"
+ :empty-state="shouldShowEmptyState"
+ :empty-title="s__('UserLists|Get started with user lists')"
+ :empty-description="
+ s__('UserLists|User lists allow you to define a set of users to use with Feature Flags.')
+ "
+ @dismissAlert="clearAlert"
+ >
+ <user-lists-table :user-lists="userLists" @delete="deleteUserList" />
+ </empty-state>
+ </div>
+ <table-pagination v-if="shouldRenderPagination" :change="onChangePage" :page-info="pageInfo" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/user_lists/components/user_lists_table.vue b/app/assets/javascripts/user_lists/components/user_lists_table.vue
new file mode 100644
index 00000000000..765f59228a6
--- /dev/null
+++ b/app/assets/javascripts/user_lists/components/user_lists_table.vue
@@ -0,0 +1,125 @@
+<script>
+import {
+ GlButton,
+ GlButtonGroup,
+ GlModal,
+ GlSprintf,
+ GlTooltipDirective,
+ GlModalDirective,
+} from '@gitlab/ui';
+import { __, s__, sprintf } from '~/locale';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+
+export default {
+ components: { GlButton, GlButtonGroup, GlModal, GlSprintf },
+ directives: { GlTooltip: GlTooltipDirective, GlModal: GlModalDirective },
+ mixins: [timeagoMixin],
+ props: {
+ userLists: {
+ type: Array,
+ required: true,
+ },
+ },
+ translations: {
+ createdTimeagoLabel: s__('UserList|created %{timeago}'),
+ deleteListTitle: s__('UserList|Delete %{name}?'),
+ deleteListMessage: s__('User list %{name} will be removed. Are you sure?'),
+ editUserListLabel: s__('FeatureFlags|Edit User List'),
+ },
+ modal: {
+ id: 'deleteListModal',
+ actionPrimary: {
+ text: __('Delete user list'),
+ attributes: { variant: 'danger', 'data-testid': 'modal-confirm' },
+ },
+ },
+ data() {
+ return {
+ deleteUserList: null,
+ };
+ },
+ computed: {
+ deleteListName() {
+ return this.deleteUserList?.name;
+ },
+ modalTitle() {
+ return sprintf(this.$options.translations.deleteListTitle, {
+ name: this.deleteListName,
+ });
+ },
+ },
+ methods: {
+ createdTimeago(list) {
+ return sprintf(this.$options.translations.createdTimeagoLabel, {
+ timeago: this.timeFormatted(list.created_at),
+ });
+ },
+ displayList(list) {
+ return list.user_xids.replace(/,/g, ', ');
+ },
+ onDelete() {
+ this.$emit('delete', this.deleteUserList);
+ },
+ confirmDeleteList(list) {
+ this.deleteUserList = list;
+ },
+ },
+};
+</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-300 gl-mb-2"
+ >
+ {{ createdTimeago(list) }}
+ </span>
+ <span data-testid="ffUserListIds" class="gl-str-truncated">{{ displayList(list) }}</span>
+ </div>
+
+ <gl-button-group class="gl-align-self-start gl-mt-2">
+ <gl-button
+ :href="list.path"
+ category="secondary"
+ icon="pencil"
+ :aria-label="$options.translations.editUserListLabel"
+ data-testid="edit-user-list"
+ />
+ <gl-button
+ v-gl-modal="$options.modal.id"
+ category="secondary"
+ variant="danger"
+ icon="remove"
+ :aria-label="$options.modal.actionPrimary.text"
+ data-testid="delete-user-list"
+ @click="confirmDeleteList(list)"
+ />
+ </gl-button-group>
+ </div>
+ <gl-modal
+ :title="modalTitle"
+ :modal-id="$options.modal.id"
+ :action-primary="$options.modal.actionPrimary"
+ static
+ @primary="onDelete"
+ >
+ <gl-sprintf :message="$options.translations.deleteListMessage">
+ <template #name>
+ <b>{{ deleteListName }}</b>
+ </template>
+ </gl-sprintf>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/user_lists/store/index/actions.js b/app/assets/javascripts/user_lists/store/index/actions.js
new file mode 100644
index 00000000000..432c576694a
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/index/actions.js
@@ -0,0 +1,38 @@
+import Api from '~/api';
+import * as types from './mutation_types';
+
+export const setUserListsOptions = ({ commit }, options) =>
+ commit(types.SET_USER_LISTS_OPTIONS, options);
+
+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 deleteUserList = ({ state, dispatch }, list) => {
+ dispatch('requestDeleteUserList', list);
+
+ return Api.deleteFeatureFlagUserList(state.projectId, list.iid)
+ .then(() => dispatch('fetchUserLists'))
+ .catch((error) =>
+ dispatch('receiveDeleteUserListError', {
+ list,
+ error: error?.response?.data ?? error,
+ }),
+ );
+};
+
+export const requestDeleteUserList = ({ commit }, list) =>
+ commit(types.REQUEST_DELETE_USER_LIST, list);
+
+export const receiveDeleteUserListError = ({ commit }, { error, list }) =>
+ commit(types.RECEIVE_DELETE_USER_LIST_ERROR, { error, list });
+export const clearAlert = ({ commit }, index) => commit(types.RECEIVE_CLEAR_ALERT, index);
diff --git a/app/assets/javascripts/user_lists/store/index/index.js b/app/assets/javascripts/user_lists/store/index/index.js
new file mode 100644
index 00000000000..9b9df59ed32
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/index/index.js
@@ -0,0 +1,11 @@
+import Vuex from 'vuex';
+import * as actions from './actions';
+import mutations from './mutations';
+import createState from './state';
+
+export default (initialState) =>
+ new Vuex.Store({
+ actions,
+ mutations,
+ state: createState(initialState),
+ });
diff --git a/app/assets/javascripts/user_lists/store/index/mutation_types.js b/app/assets/javascripts/user_lists/store/index/mutation_types.js
new file mode 100644
index 00000000000..5637ed60b7b
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/index/mutation_types.js
@@ -0,0 +1,10 @@
+export const SET_USER_LISTS_OPTIONS = 'SET_FEATURE_FLAGS_OPTIONS';
+
+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 REQUEST_DELETE_USER_LIST = 'REQUEST_DELETE_USER_LIST';
+export const RECEIVE_DELETE_USER_LIST_ERROR = 'RECEIVE_DELETE_USER_LIST_ERROR';
+
+export const RECEIVE_CLEAR_ALERT = 'RECEIVE_CLEAR_ALERT';
diff --git a/app/assets/javascripts/user_lists/store/index/mutations.js b/app/assets/javascripts/user_lists/store/index/mutations.js
new file mode 100644
index 00000000000..8e2865dc165
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/index/mutations.js
@@ -0,0 +1,37 @@
+import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_USER_LISTS_OPTIONS](state, options = {}) {
+ state.options = options;
+ },
+ [types.REQUEST_USER_LISTS](state) {
+ state.isLoading = true;
+ },
+ [types.RECEIVE_USER_LISTS_SUCCESS](state, { data, headers }) {
+ state.isLoading = false;
+ state.hasError = false;
+ state.userLists = data || [];
+
+ const normalizedHeaders = normalizeHeaders(headers);
+ const paginationInfo = parseIntPagination(normalizedHeaders);
+ state.count = paginationInfo?.total ?? state.userLists.length;
+ state.pageInfo = paginationInfo;
+ },
+ [types.RECEIVE_USER_LISTS_ERROR](state) {
+ state.isLoading = false;
+ state.hasError = true;
+ },
+ [types.REQUEST_DELETE_USER_LIST](state, list) {
+ state.userLists = state.userLists.filter((l) => l !== list);
+ },
+ [types.RECEIVE_DELETE_USER_LIST_ERROR](state, { error, list }) {
+ state.isLoading = false;
+ state.hasError = false;
+ state.alerts = [].concat(error.message);
+ state.userLists = state.userLists.concat(list).sort((l1, l2) => l1.iid - l2.iid);
+ },
+ [types.RECEIVE_CLEAR_ALERT](state, index) {
+ state.alerts.splice(index, 1);
+ },
+};
diff --git a/app/assets/javascripts/user_lists/store/index/state.js b/app/assets/javascripts/user_lists/store/index/state.js
new file mode 100644
index 00000000000..0658d23cffc
--- /dev/null
+++ b/app/assets/javascripts/user_lists/store/index/state.js
@@ -0,0 +1,10 @@
+export default ({ projectId }) => ({
+ userLists: [],
+ alerts: [],
+ count: 0,
+ pageInfo: {},
+ isLoading: true,
+ hasError: false,
+ options: {},
+ projectId,
+});