diff options
Diffstat (limited to 'app/assets/javascripts/packages_and_registries/infrastructure_registry/list')
12 files changed, 622 insertions, 0 deletions
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue new file mode 100644 index 00000000000..c611f92036d --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue @@ -0,0 +1,45 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { LIST_KEY_PACKAGE_TYPE } from '~/packages_and_registries/infrastructure_registry/list/constants'; +import { sortableFields } from '~/packages_and_registries/infrastructure_registry/list/utils'; +import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; +import UrlSync from '~/vue_shared/components/url_sync.vue'; + +export default { + components: { RegistrySearch, UrlSync }, + computed: { + ...mapState({ + isGroupPage: (state) => state.config.isGroupPage, + sorting: (state) => state.sorting, + filter: (state) => state.filter, + }), + sortableFields() { + return sortableFields(this.isGroupPage).filter((h) => h.orderBy !== LIST_KEY_PACKAGE_TYPE); + }, + }, + methods: { + ...mapActions(['setSorting', 'setFilter']), + updateSorting(newValue) { + this.setSorting(newValue); + this.$emit('update'); + }, + }, +}; +</script> + +<template> + <url-sync> + <template #default="{ updateQuery }"> + <registry-search + :filter="filter" + :sorting="sorting" + :tokens="[]" + :sortable-fields="sortableFields" + @sorting:changed="updateSorting" + @filter:changed="setFilter" + @filter:submit="$emit('update')" + @query:changed="updateQuery" + /> + </template> + </url-sync> +</template> diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue new file mode 100644 index 00000000000..2a479c65d0c --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue @@ -0,0 +1,53 @@ +<script> +import { s__, n__ } from '~/locale'; +import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; + +export default { + name: 'InfrastructureTitle', + components: { + TitleArea, + MetadataItem, + }, + props: { + count: { + type: Number, + required: false, + default: null, + }, + helpUrl: { + type: String, + required: true, + }, + }, + computed: { + showModuleCount() { + return Number.isInteger(this.count); + }, + moduleAmountText() { + return n__(`%d Module`, `%d Modules`, this.count); + }, + infoMessages() { + return [{ text: this.$options.i18n.LIST_INTRO_TEXT, link: this.helpUrl }]; + }, + }, + i18n: { + LIST_TITLE_TEXT: s__('InfrastructureRegistry|Infrastructure Registry'), + LIST_INTRO_TEXT: s__( + 'InfrastructureRegistry|Publish and share your modules. %{docLinkStart}More information%{docLinkEnd}', + ), + }, +}; +</script> + +<template> + <title-area :title="$options.i18n.LIST_TITLE_TEXT" :info-messages="infoMessages"> + <template #metadata-amount> + <metadata-item + v-if="showModuleCount" + icon="infrastructure-registry" + :text="moduleAmountText" + /> + </template> + </title-area> +</template> diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue new file mode 100644 index 00000000000..a5f367bc1f6 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue @@ -0,0 +1,127 @@ +<script> +import { GlPagination, GlModal, GlSprintf } from '@gitlab/ui'; +import { mapState, mapGetters } from 'vuex'; +import { s__ } from '~/locale'; +import Tracking from '~/tracking'; +import PackagesListRow from '~/packages_and_registries/infrastructure_registry/shared/package_list_row.vue'; +import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; +import { TRACKING_ACTIONS } from '~/packages_and_registries/shared/constants'; +import { TRACK_CATEGORY } from '~/packages_and_registries/infrastructure_registry/shared/constants'; + +export default { + components: { + GlPagination, + GlModal, + GlSprintf, + PackagesListLoader, + PackagesListRow, + }, + mixins: [Tracking.mixin()], + data() { + return { + itemToBeDeleted: null, + }; + }, + computed: { + ...mapState({ + perPage: (state) => state.pagination.perPage, + totalItems: (state) => state.pagination.total, + page: (state) => state.pagination.page, + isGroupPage: (state) => state.config.isGroupPage, + isLoading: 'isLoading', + }), + ...mapGetters({ list: 'getList' }), + currentPage: { + get() { + return this.page; + }, + set(value) { + this.$emit('page:changed', value); + }, + }, + isListEmpty() { + return !this.list || this.list.length === 0; + }, + modalAction() { + return s__('PackageRegistry|Delete package'); + }, + deletePackageName() { + return this.itemToBeDeleted?.name ?? ''; + }, + tracking() { + return { + category: TRACK_CATEGORY, + }; + }, + }, + methods: { + setItemToBeDeleted(item) { + this.itemToBeDeleted = { ...item }; + this.track(TRACKING_ACTIONS.REQUEST_DELETE_PACKAGE); + this.$refs.packageListDeleteModal.show(); + }, + deleteItemConfirmation() { + this.$emit('package:delete', this.itemToBeDeleted); + this.track(TRACKING_ACTIONS.DELETE_PACKAGE); + this.itemToBeDeleted = null; + }, + deleteItemCanceled() { + this.track(TRACKING_ACTIONS.CANCEL_DELETE_PACKAGE); + this.itemToBeDeleted = null; + }, + }, + i18n: { + deleteModalContent: s__( + 'PackageRegistry|You are about to delete %{name}, this operation is irreversible, are you sure?', + ), + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-flex-direction-column"> + <slot v-if="isListEmpty && !isLoading" name="empty-state"></slot> + + <div v-else-if="isLoading"> + <packages-list-loader /> + </div> + + <template v-else> + <div data-qa-selector="packages-table"> + <packages-list-row + v-for="packageEntity in list" + :key="packageEntity.id" + :package-entity="packageEntity" + :package-link="packageEntity._links.web_path" + :is-group="isGroupPage" + @packageToDelete="setItemToBeDeleted" + /> + </div> + + <gl-pagination + v-model="currentPage" + :per-page="perPage" + :total-items="totalItems" + align="center" + class="gl-w-full gl-mt-3" + /> + + <gl-modal + ref="packageListDeleteModal" + size="sm" + modal-id="confirm-delete-pacakge" + ok-variant="danger" + @ok="deleteItemConfirmation" + @cancel="deleteItemCanceled" + > + <template #modal-title>{{ modalAction }}</template> + <template #modal-ok>{{ modalAction }}</template> + <gl-sprintf :message="$options.i18n.deleteModalContent"> + <template #name> + <strong>{{ deletePackageName }}</strong> + </template> + </gl-sprintf> + </gl-modal> + </template> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue new file mode 100644 index 00000000000..462618a7f12 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue @@ -0,0 +1,119 @@ +<script> +import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import { mapActions, mapState } from 'vuex'; +import createFlash from '~/flash'; +import { historyReplaceState } from '~/lib/utils/common_utils'; +import { s__ } from '~/locale'; +import { + SHOW_DELETE_SUCCESS_ALERT, + FILTERED_SEARCH_TERM, +} from '~/packages_and_registries/shared/constants'; + +import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils'; +import InfrastructureTitle from '~/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue'; +import InfrastructureSearch from '~/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue'; +import PackageList from '~/packages_and_registries/infrastructure_registry/list/components/packages_list.vue'; +import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages_and_registries/infrastructure_registry/list/constants'; + +export default { + components: { + GlEmptyState, + GlLink, + GlSprintf, + PackageList, + InfrastructureTitle, + InfrastructureSearch, + }, + inject: { + emptyPageTitle: { + from: 'emptyPageTitle', + default: s__('PackageRegistry|There are no packages yet'), + }, + noResultsText: { + from: 'noResultsText', + default: s__( + 'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.', + ), + }, + }, + computed: { + ...mapState({ + emptyListIllustration: (state) => state.config.emptyListIllustration, + emptyListHelpUrl: (state) => state.config.emptyListHelpUrl, + filter: (state) => state.filter, + selectedType: (state) => state.selectedType, + packageHelpUrl: (state) => state.config.packageHelpUrl, + packagesCount: (state) => state.pagination?.total, + }), + emptySearch() { + return ( + this.filter.filter((f) => f.type !== FILTERED_SEARCH_TERM || f.value?.data).length === 0 + ); + }, + + emptyStateTitle() { + return this.emptySearch + ? this.emptyPageTitle + : s__('PackageRegistry|Sorry, your filter produced no results'); + }, + }, + mounted() { + const queryParams = getQueryParams(window.document.location.search); + const { sorting, filters } = extractFilterAndSorting(queryParams); + this.setSorting(sorting); + this.setFilter(filters); + this.requestPackagesList(); + this.checkDeleteAlert(); + }, + methods: { + ...mapActions([ + 'requestPackagesList', + 'requestDeletePackage', + 'setSelectedType', + 'setSorting', + 'setFilter', + ]), + onPageChanged(page) { + return this.requestPackagesList({ page }); + }, + onPackageDeleteRequest(item) { + return this.requestDeletePackage(item); + }, + checkDeleteAlert() { + const urlParams = new URLSearchParams(window.location.search); + const showAlert = urlParams.get(SHOW_DELETE_SUCCESS_ALERT); + if (showAlert) { + // to be refactored to use gl-alert + createFlash({ message: DELETE_PACKAGE_SUCCESS_MESSAGE, type: 'notice' }); + const cleanUrl = window.location.href.split('?')[0]; + historyReplaceState(cleanUrl); + } + }, + }, + i18n: { + widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'), + }, +}; +</script> + +<template> + <div> + <infrastructure-title :help-url="packageHelpUrl" :count="packagesCount" /> + <infrastructure-search @update="requestPackagesList" /> + + <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest"> + <template #empty-state> + <gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration"> + <template #description> + <gl-sprintf v-if="!emptySearch" :message="$options.i18n.widenFilters" /> + <gl-sprintf v-else :message="noResultsText"> + <template #noPackagesLink="{ content }"> + <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </template> + </gl-empty-state> + </template> + </package-list> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/constants.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/constants.js new file mode 100644 index 00000000000..7af3fc1c2db --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/constants.js @@ -0,0 +1,51 @@ +import { __ } from '~/locale'; + +export const FETCH_PACKAGES_LIST_ERROR_MESSAGE = __( + 'Something went wrong while fetching the packages list.', +); +export const DELETE_PACKAGE_SUCCESS_MESSAGE = __('Package deleted successfully'); + +export const DEFAULT_PAGE = 1; +export const DEFAULT_PAGE_SIZE = 20; + +export const GROUP_PAGE_TYPE = 'groups'; + +export const LIST_KEY_NAME = 'name'; +export const LIST_KEY_PROJECT = 'project_path'; +export const LIST_KEY_VERSION = 'version'; +export const LIST_KEY_PACKAGE_TYPE = 'type'; +export const LIST_KEY_CREATED_AT = 'created_at'; + +export const LIST_LABEL_NAME = __('Name'); +export const LIST_LABEL_PROJECT = __('Project'); +export const LIST_LABEL_VERSION = __('Version'); +export const LIST_LABEL_PACKAGE_TYPE = __('Type'); +export const LIST_LABEL_CREATED_AT = __('Published'); + +// The following is not translated because it is used to build a JavaScript exception error message +export const MISSING_DELETE_PATH_ERROR = 'Missing delete_api_path link'; + +export const SORT_FIELDS = [ + { + orderBy: LIST_KEY_NAME, + label: LIST_LABEL_NAME, + }, + { + orderBy: LIST_KEY_PROJECT, + label: LIST_LABEL_PROJECT, + }, + { + orderBy: LIST_KEY_VERSION, + label: LIST_LABEL_VERSION, + }, + { + orderBy: LIST_KEY_PACKAGE_TYPE, + label: LIST_LABEL_PACKAGE_TYPE, + }, + { + orderBy: LIST_KEY_CREATED_AT, + label: LIST_LABEL_CREATED_AT, + }, +]; + +export const TERRAFORM_SEARCH_TYPE = Object.freeze({ value: { data: 'terraform_module' } }); diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js new file mode 100644 index 00000000000..488860e5bc2 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js @@ -0,0 +1,83 @@ +import Api from '~/api'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages_and_registries/shared/constants'; +import { + FETCH_PACKAGES_LIST_ERROR_MESSAGE, + DELETE_PACKAGE_SUCCESS_MESSAGE, + DEFAULT_PAGE, + DEFAULT_PAGE_SIZE, + MISSING_DELETE_PATH_ERROR, + TERRAFORM_SEARCH_TYPE, +} from '../constants'; +import { getNewPaginationPage } from '../utils'; +import * as types from './mutation_types'; + +export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data); +export const setLoading = ({ commit }, data) => commit(types.SET_MAIN_LOADING, data); +export const setSorting = ({ commit }, data) => commit(types.SET_SORTING, data); +export const setFilter = ({ commit }, data) => commit(types.SET_FILTER, data); + +export const receivePackagesListSuccess = ({ commit }, { data, headers }) => { + commit(types.SET_PACKAGE_LIST_SUCCESS, data); + commit(types.SET_PAGINATION, headers); +}; + +export const requestPackagesList = ({ dispatch, state }, params = {}) => { + dispatch('setLoading', true); + + const { page = DEFAULT_PAGE, per_page = DEFAULT_PAGE_SIZE } = params; + const { sort, orderBy } = state.sorting; + const type = state.config.forceTerraform + ? TERRAFORM_SEARCH_TYPE + : state.filter.find((f) => f.type === 'type'); + const name = state.filter.find((f) => f.type === 'filtered-search-term'); + const packageFilters = { package_type: type?.value?.data, package_name: name?.value?.data }; + + const apiMethod = state.config.isGroupPage ? 'groupPackages' : 'projectPackages'; + + return Api[apiMethod](state.config.resourceId, { + params: { page, per_page, sort, order_by: orderBy, ...packageFilters }, + }) + .then(({ data, headers }) => { + dispatch('receivePackagesListSuccess', { data, headers }); + }) + .catch(() => { + createFlash({ + message: FETCH_PACKAGES_LIST_ERROR_MESSAGE, + }); + }) + .finally(() => { + dispatch('setLoading', false); + }); +}; + +export const requestDeletePackage = ({ dispatch, state }, { _links }) => { + if (!_links || !_links.delete_api_path) { + createFlash({ + message: DELETE_PACKAGE_ERROR_MESSAGE, + }); + const error = new Error(MISSING_DELETE_PATH_ERROR); + return Promise.reject(error); + } + + dispatch('setLoading', true); + return axios + .delete(_links.delete_api_path) + .then(() => { + const { page: currentPage, perPage, total } = state.pagination; + const page = getNewPaginationPage(currentPage, perPage, total - 1); + + dispatch('requestPackagesList', { page }); + createFlash({ + message: DELETE_PACKAGE_SUCCESS_MESSAGE, + type: 'success', + }); + }) + .catch(() => { + dispatch('setLoading', false); + createFlash({ + message: DELETE_PACKAGE_ERROR_MESSAGE, + }); + }); +}; diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/getters.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/getters.js new file mode 100644 index 00000000000..5989303280e --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/getters.js @@ -0,0 +1,5 @@ +import { beautifyPath } from '~/packages_and_registries/shared/utils'; +import { LIST_KEY_PROJECT } from '../constants'; + +export default (state) => + state.packages.map((p) => ({ ...p, projectPathName: beautifyPath(p[LIST_KEY_PROJECT]) })); diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/index.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/index.js new file mode 100644 index 00000000000..1d6a4bf831d --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/index.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import getList from './getters'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export const createStore = () => + new Vuex.Store({ + state, + getters: { + getList, + }, + actions, + mutations, + }); + +export default createStore(); diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/mutation_types.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/mutation_types.js new file mode 100644 index 00000000000..561ad97f7e3 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/mutation_types.js @@ -0,0 +1,7 @@ +export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; + +export const SET_PACKAGE_LIST_SUCCESS = 'SET_PACKAGE_LIST_SUCCESS'; +export const SET_PAGINATION = 'SET_PAGINATION'; +export const SET_MAIN_LOADING = 'SET_MAIN_LOADING'; +export const SET_SORTING = 'SET_SORTING'; +export const SET_FILTER = 'SET_FILTER'; diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/mutations.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/mutations.js new file mode 100644 index 00000000000..98165e581b0 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/mutations.js @@ -0,0 +1,33 @@ +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import { GROUP_PAGE_TYPE } from '../constants'; +import * as types from './mutation_types'; + +export default { + [types.SET_INITIAL_STATE](state, config) { + state.config = { + ...config, + isGroupPage: config.pageType === GROUP_PAGE_TYPE, + }; + }, + + [types.SET_PACKAGE_LIST_SUCCESS](state, packages) { + state.packages = packages; + }, + + [types.SET_MAIN_LOADING](state, isLoading) { + state.isLoading = isLoading; + }, + + [types.SET_PAGINATION](state, headers) { + const normalizedHeaders = normalizeHeaders(headers); + state.pagination = parseIntPagination(normalizedHeaders); + }, + + [types.SET_SORTING](state, sorting) { + state.sorting = { ...state.sorting, ...sorting }; + }, + + [types.SET_FILTER](state, filter) { + state.filter = filter; + }, +}; diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/state.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/state.js new file mode 100644 index 00000000000..60f02eddc9f --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/state.js @@ -0,0 +1,54 @@ +export default () => ({ + /** + * Determine if the component is loading data from the API + */ + isLoading: false, + /** + * configuration object, set once at store creation with the following structure + * { + * resourceId: String, + * pageType: String, + * emptyListIllustration: String, + * emptyListHelpUrl: String, + * comingSoon: { projectPath: String, suggestedContributions : String } | null; + * } + */ + config: {}, + /** + * Each object in `packages` has the following structure: + * { + * id: String + * name: String, + * version: String, + * package_type: String // endpoint to request the list + * } + */ + packages: [], + /** + * Pagination object has the following structure: + * { + * perPage: Number, + * page: Number + * total: Number + * } + */ + pagination: {}, + /** + * Sorting object has the following structure: + * { + * sort: String, + * orderBy: String + * } + */ + sorting: { + sort: 'desc', + orderBy: 'created_at', + }, + /** + * The search query that is used to filter packages by name + */ + filter: [], + /** + * The selected TAB of the package types tabs + */ +}); diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/utils.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/utils.js new file mode 100644 index 00000000000..537b30d2ca4 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/utils.js @@ -0,0 +1,25 @@ +import { LIST_KEY_PROJECT, SORT_FIELDS } from './constants'; + +export const sortableFields = (isGroupPage) => + SORT_FIELDS.filter((f) => f.orderBy !== LIST_KEY_PROJECT || isGroupPage); + +/** + * A small util function that works out if the delete action has deleted the + * last item on the current paginated page and if so, returns the previous + * page. This ensures the user won't end up on an empty paginated page. + * + * @param {number} currentPage The current page the user is on + * @param {number} perPage Number of items to display per page + * @param {number} totalPackages The total number of items + */ +export const getNewPaginationPage = (currentPage, perPage, totalItems) => { + if (totalItems <= perPage) { + return 1; + } + + if (currentPage > 1 && (currentPage - 1) * perPage >= totalItems) { + return currentPage - 1; + } + + return currentPage; +}; |