From e06cb80ea5c6b0eca29f677113a12ade083a50dd Mon Sep 17 00:00:00 2001 From: Luke Bennett <lbennett@gitlab.com> Date: Thu, 29 Nov 2018 01:06:10 +0000 Subject: [PATCH] Simplify admin instance licenses page Displays all licenses on the admin license page. Allows for downloading and delete previous licenses. --- .../stylesheets/framework/variables.scss | 5 + .../licenses/components/cards/index.js | 4 + .../components/cards/license_card.vue | 82 +++++++ .../components/cards/license_card_body.vue | 103 +++++++++ .../cards/skeleton_license_card.vue | 48 ++++ .../licenses/components/cells/cell.vue | 52 +++++ .../licenses/components/cells/date_cell.vue | 62 +++++ .../licenses/components/cells/header_cell.vue | 31 +++ .../licenses/components/cells/index.js | 8 + .../licenses/components/cells/info_cell.vue | 58 +++++ .../components/cells/skeleton_cell.vue | 19 ++ .../components/cells/skeleton_header_cell.vue | 18 ++ .../components/license_cards_list.vue | 44 ++++ ee/app/assets/javascripts/licenses/index.js | 38 +++ .../javascripts/licenses/store/actions.js | 46 ++++ .../licenses/store/flash_message.js | 21 ++ .../javascripts/licenses/store/getters.js | 3 + .../javascripts/licenses/store/index.js | 17 ++ .../licenses/store/mutation_types.js | 9 + .../javascripts/licenses/store/mutations.js | 41 ++++ .../javascripts/licenses/store/state.js | 10 + .../pages/admin/licenses/show/index.js | 6 + ee/app/assets/stylesheets/pages/licenses.scss | 61 +++++ ee/app/helpers/license_helper.rb | 6 + ee/app/views/admin/licenses/show.html.haml | 200 ++++++++-------- .../improve-admin-licence-page-rest-fe.yml | 5 + .../licenses/admin_uploads_license_spec.rb | 1 + .../licenses/admin_views_license_spec.rb | 1 + .../admin/licenses/licenses_app_spec.rb | 101 ++++++++ .../license_cards_list_spec.js.snap | 103 +++++++++ .../license_card_body_spec.js.snap | 217 ++++++++++++++++++ .../__snapshots__/license_card_spec.js.snap | 42 ++++ .../skeleton_license_card_spec.js.snap | 62 +++++ .../cards/license_card_body_spec.js | 57 +++++ .../components/cards/license_card_spec.js | 50 ++++ .../cards/skeleton_license_card_spec.js | 20 ++ .../cells/__snapshots__/cell_spec.js.snap | 89 +++++++ .../__snapshots__/date_cell_spec.js.snap | 79 +++++++ .../__snapshots__/header_cell_spec.js.snap | 22 ++ .../__snapshots__/info_cell_spec.js.snap | 67 ++++++ .../__snapshots__/skeleton_cell_spec.js.snap | 17 ++ .../skeleton_header_cell_spec.js.snap | 12 + .../licenses/components/cells/cell_spec.js | 47 ++++ .../components/cells/date_cell_spec.js | 52 +++++ .../components/cells/header_cell_spec.js | 25 ++ .../components/cells/info_cell_spec.js | 36 +++ .../components/cells/skeleton_cell_spec.js | 20 ++ .../cells/skeleton_header_cell_spec.js | 20 ++ .../components/license_cards_list_spec.js | 50 ++++ locale/gitlab.pot | 93 ++++++++ 50 files changed, 2182 insertions(+), 98 deletions(-) create mode 100644 ee/app/assets/javascripts/licenses/components/cards/index.js create mode 100644 ee/app/assets/javascripts/licenses/components/cards/license_card.vue create mode 100644 ee/app/assets/javascripts/licenses/components/cards/license_card_body.vue create mode 100644 ee/app/assets/javascripts/licenses/components/cards/skeleton_license_card.vue create mode 100644 ee/app/assets/javascripts/licenses/components/cells/cell.vue create mode 100644 ee/app/assets/javascripts/licenses/components/cells/date_cell.vue create mode 100644 ee/app/assets/javascripts/licenses/components/cells/header_cell.vue create mode 100644 ee/app/assets/javascripts/licenses/components/cells/index.js create mode 100644 ee/app/assets/javascripts/licenses/components/cells/info_cell.vue create mode 100644 ee/app/assets/javascripts/licenses/components/cells/skeleton_cell.vue create mode 100644 ee/app/assets/javascripts/licenses/components/cells/skeleton_header_cell.vue create mode 100644 ee/app/assets/javascripts/licenses/components/license_cards_list.vue create mode 100644 ee/app/assets/javascripts/licenses/index.js create mode 100644 ee/app/assets/javascripts/licenses/store/actions.js create mode 100644 ee/app/assets/javascripts/licenses/store/flash_message.js create mode 100644 ee/app/assets/javascripts/licenses/store/getters.js create mode 100644 ee/app/assets/javascripts/licenses/store/index.js create mode 100644 ee/app/assets/javascripts/licenses/store/mutation_types.js create mode 100644 ee/app/assets/javascripts/licenses/store/mutations.js create mode 100644 ee/app/assets/javascripts/licenses/store/state.js create mode 100644 ee/app/assets/javascripts/pages/admin/licenses/show/index.js create mode 100644 ee/changelogs/unreleased/improve-admin-licence-page-rest-fe.yml create mode 100644 ee/spec/features/admin/licenses/licenses_app_spec.rb create mode 100644 ee/spec/frontend/licenses/components/__snapshots__/license_cards_list_spec.js.snap create mode 100644 ee/spec/frontend/licenses/components/cards/__snapshots__/license_card_body_spec.js.snap create mode 100644 ee/spec/frontend/licenses/components/cards/__snapshots__/license_card_spec.js.snap create mode 100644 ee/spec/frontend/licenses/components/cards/__snapshots__/skeleton_license_card_spec.js.snap create mode 100644 ee/spec/frontend/licenses/components/cards/license_card_body_spec.js create mode 100644 ee/spec/frontend/licenses/components/cards/license_card_spec.js create mode 100644 ee/spec/frontend/licenses/components/cards/skeleton_license_card_spec.js create mode 100644 ee/spec/frontend/licenses/components/cells/__snapshots__/cell_spec.js.snap create mode 100644 ee/spec/frontend/licenses/components/cells/__snapshots__/date_cell_spec.js.snap create mode 100644 ee/spec/frontend/licenses/components/cells/__snapshots__/header_cell_spec.js.snap create mode 100644 ee/spec/frontend/licenses/components/cells/__snapshots__/info_cell_spec.js.snap create mode 100644 ee/spec/frontend/licenses/components/cells/__snapshots__/skeleton_cell_spec.js.snap create mode 100644 ee/spec/frontend/licenses/components/cells/__snapshots__/skeleton_header_cell_spec.js.snap create mode 100644 ee/spec/frontend/licenses/components/cells/cell_spec.js create mode 100644 ee/spec/frontend/licenses/components/cells/date_cell_spec.js create mode 100644 ee/spec/frontend/licenses/components/cells/header_cell_spec.js create mode 100644 ee/spec/frontend/licenses/components/cells/info_cell_spec.js create mode 100644 ee/spec/frontend/licenses/components/cells/skeleton_cell_spec.js create mode 100644 ee/spec/frontend/licenses/components/cells/skeleton_header_cell_spec.js create mode 100644 ee/spec/frontend/licenses/components/license_cards_list_spec.js diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 7d9781ffb87..b943f2073ba 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -718,3 +718,8 @@ $compare-branches-sticky-header-height: 68px; - Issue: https://gitlab.com/gitlab-org/design.gitlab.com/issues/242 */ $enable-validation-icons: false; + +/* +Licenses +*/ +$license-header-cell-width: 150px; diff --git a/ee/app/assets/javascripts/licenses/components/cards/index.js b/ee/app/assets/javascripts/licenses/components/cards/index.js new file mode 100644 index 00000000000..507b97c5807 --- /dev/null +++ b/ee/app/assets/javascripts/licenses/components/cards/index.js @@ -0,0 +1,4 @@ +import LicenseCard from './license_card.vue'; +import SkeletonLicenseCard from './skeleton_license_card.vue'; + +export { LicenseCard, SkeletonLicenseCard }; diff --git a/ee/app/assets/javascripts/licenses/components/cards/license_card.vue b/ee/app/assets/javascripts/licenses/components/cards/license_card.vue new file mode 100644 index 00000000000..ea240fc6ba6 --- /dev/null +++ b/ee/app/assets/javascripts/licenses/components/cards/license_card.vue @@ -0,0 +1,82 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { __ } from '~/locale'; +import LicenseCardBody from './license_card_body.vue'; + +export default { + name: 'LicenseCard', + components: { + LicenseCardBody, + GlDropdown, + GlDropdownItem, + }, + props: { + license: { + type: Object, + required: false, + default() { + return { licensee: {} }; + }, + }, + isCurrentLicense: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + ...mapState(['currentActiveUserCount', 'deleteQueue', 'downloadLicensePath']), + isRemoving() { + return this.deleteQueue.includes(this.license.id); + }, + }, + methods: { + ...mapActions(['fetchDeleteLicense']), + capitalizeFirstCharacter, + confirmDeleteLicense(...args) { + window.confirm(__('Are you sure you want to permanently delete this license?')); // eslint-disable-line no-alert + this.fetchDeleteLicense(...args); + }, + }, +}; +</script> + +<template> + <div class="card license-card mb-5"> + <div class="card-header"> + <div class="d-flex justify-content-between align-items-center"> + <h4> + {{ + sprintf(__('GitLab Enterprise Edition %{plan}'), { + plan: capitalizeFirstCharacter(license.plan), + }) + }} + </h4> + + <gl-dropdown right class="js-manage-license" :text="__('Manage')" :disabled="isRemoving"> + <gl-dropdown-item + v-if="isCurrentLicense" + class="js-download-license" + :href="downloadLicensePath" + > + {{ __('Download license') }} + </gl-dropdown-item> + <gl-dropdown-item + class="js-delete-license text-danger" + @click="confirmDeleteLicense(license)" + > + {{ __('Delete license') }} + </gl-dropdown-item> + </gl-dropdown> + </div> + </div> + + <license-card-body + :license="license" + :is-removing="isRemoving" + :current-active-user-count="currentActiveUserCount" + /> + </div> +</template> diff --git a/ee/app/assets/javascripts/licenses/components/cards/license_card_body.vue b/ee/app/assets/javascripts/licenses/components/cards/license_card_body.vue new file mode 100644 index 00000000000..847914ad469 --- /dev/null +++ b/ee/app/assets/javascripts/licenses/components/cards/license_card_body.vue @@ -0,0 +1,103 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import { __ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import { Cell, HeaderCell, InfoCell, DateCell } from '../cells'; + +export default { + name: 'LicenseCardBody', + components: { + Icon, + Cell, + HeaderCell, + InfoCell, + DateCell, + GlLink, + }, + props: { + license: { + type: Object, + required: false, + default() { + return { + licensee: {}, + }; + }, + }, + isRemoving: { + type: Boolean, + required: false, + default: false, + }, + currentActiveUserCount: { + type: Number, + required: true, + }, + }, + data() { + return { + info: { + currentActiveUserCount: __( + "Users with a Guest role or those who don't belong to any projects or groups don't count towards seats in use.", + ), + historicalMax: __(`This is the maximum number of users that have existed at the same time since the license started. + This is the minimum number of seats you will need to buy when you renew your license.`), + overage: __(`GitLab allows you to continue using your license even if you exceed the number of seats you purchased. + You will be required to pay for these seats when you renew your license.`), + }, + }; + }, + methods: { + licenseeValue(key) { + return this.license.licensee[key] || __('Unknown'); + }, + }, +}; +</script> + +<template> + <div class="card-body license-card-body p-0"> + <div + v-if="isRemoving" + class="p-5 d-flex justify-content-center align-items-center license-card-loading" + > + <icon name="spinner" /><span class="ml-2">{{ __('Removing license…') }}</span> + </div> + + <div v-else class="license-table js-license-table"> + <div class="license-row d-flex"> + <header-cell :title="__('Usage')" icon="monitor" /> + <cell :title="__('Seats in license')" :value="license.userLimit || __('Unlimited')" /> + <info-cell + :title="__('Seats currently in use')" + :value="currentActiveUserCount" + :popover-content="info.currentActiveUserCount" + /> + <info-cell + :title="__('Max seats used')" + :value="license.historicalMax" + :popover-content="info.historicalMax" + /> + <info-cell + :title="__('Users outside of license')" + :value="license.overage" + :popover-content="info.overage" + /> + </div> + + <div class="license-row d-flex"> + <header-cell :title="__('Validity')" icon="calendar" /> + <date-cell :title="__('Start date')" :value="license.startsAt" /> + <date-cell :title="__('End date')" :value="license.expiresAt" :is-expirable="true" /> + <date-cell :title="__('Uploaded on')" :value="license.createdAt" /> + </div> + + <div class="license-row d-flex"> + <header-cell :title="__('Registration')" icon="user" /> + <cell :title="__('Licensed to')" :value="licenseeValue('Name')" /> + <cell :title="__('Email address')" :value="licenseeValue('Email')" /> + <cell :title="__('Company')" :value="licenseeValue('Company')" /> + </div> + </div> + </div> +</template> diff --git a/ee/app/assets/javascripts/licenses/components/cards/skeleton_license_card.vue b/ee/app/assets/javascripts/licenses/components/cards/skeleton_license_card.vue new file mode 100644 index 00000000000..f307661a3f3 --- /dev/null +++ b/ee/app/assets/javascripts/licenses/components/cards/skeleton_license_card.vue @@ -0,0 +1,48 @@ +<script> +import { GlSkeletonLoading } from '@gitlab/ui'; +import { Cell, SkeletonCell, SkeletonHeaderCell } from '../cells'; + +export default { + name: 'SkeletonLicenseCard', + components: { + GlSkeletonLoading, + Cell, + SkeletonCell, + SkeletonHeaderCell, + }, +}; +</script> + +<template> + <div class="card license-card skeleton-license-card"> + <div class="card-header d-flex justify-content-between align-items-center py-3"> + <gl-skeleton-loading class="w-75 skeleton-bar" :lines="1" /> + </div> + + <div class="card-body p-0"> + <div class="license-table"> + <div class="license-row d-flex"> + <skeleton-header-cell /> + <skeleton-cell /> + <skeleton-cell /> + <skeleton-cell /> + <skeleton-cell /> + </div> + + <div class="license-row d-flex"> + <skeleton-header-cell /> + <skeleton-cell /> + <skeleton-cell /> + <skeleton-cell /> + </div> + + <div class="license-row d-flex"> + <skeleton-header-cell /> + <skeleton-cell /> + <skeleton-cell /> + <skeleton-cell /> + </div> + </div> + </div> + </div> +</template> diff --git a/ee/app/assets/javascripts/licenses/components/cells/cell.vue b/ee/app/assets/javascripts/licenses/components/cells/cell.vue new file mode 100644 index 00000000000..9de970becf1 --- /dev/null +++ b/ee/app/assets/javascripts/licenses/components/cells/cell.vue @@ -0,0 +1,52 @@ +<script> +import _ from 'underscore'; +import { GlSkeletonLoading } from '@gitlab/ui'; + +export default { + name: 'Cell', + components: { + GlSkeletonLoading, + }, + props: { + title: { + type: String, + required: false, + default: null, + }, + value: { + type: [String, Number], + required: false, + default: null, + }, + isFlexible: { + type: Boolean, + required: false, + default: true, + }, + }, + computed: { + valueClass() { + return { number: _.isNumber(this.value) }; + }, + flexClass() { + return { 'flex-grow-1': this.isFlexible }; + }, + }, +}; +</script> + +<template> + <div class="license-cell p-3 text-nowrap flex-shrink-0" :class="flexClass"> + <span class="title d-flex align-items-center justify-content-start"> + <slot name="title"> + <span>{{ title }}</span> + </slot> + </span> + + <div class="value mt-2" :class="valueClass"> + <slot name="value"> + <span>{{ value }}</span> + </slot> + </div> + </div> +</template> diff --git a/ee/app/assets/javascripts/licenses/components/cells/date_cell.vue b/ee/app/assets/javascripts/licenses/components/cells/date_cell.vue new file mode 100644 index 00000000000..13f6dff69ec --- /dev/null +++ b/ee/app/assets/javascripts/licenses/components/cells/date_cell.vue @@ -0,0 +1,62 @@ +<script> +import { dateInWords } from '~/lib/utils/datetime_utility'; +import { __ } from '~/locale'; +import Cell from './cell.vue'; + +export default { + name: 'DateCell', + components: { + Cell, + }, + props: { + title: { + type: String, + required: false, + default: null, + }, + value: { + type: [String, Date], + required: false, + default: null, + }, + dateNow: { + type: Date, + required: false, + default() { + return new Date(); + }, + }, + isExpirable: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + dateInWordsValue() { + return dateInWords(this.dateValue); + }, + dateValue() { + return new Date(this.value); + }, + isExpired() { + return this.isExpirable && this.dateValue < this.dateNow; + }, + valueClass() { + return { 'text-danger': this.isExpired }; + }, + fallbackValue() { + return this.isExpirable ? this.dateInWords || __('Never') : this.dateInWords; + }, + }, +}; +</script> + +<template> + <cell :title="title" :value="fallbackValue"> + <div v-if="value" slot="value" :class="valueClass"> + {{ dateInWordsValue }} + <span v-if="isExpired"> - {{ __('Expired') }} </span> + </div> + </cell> +</template> diff --git a/ee/app/assets/javascripts/licenses/components/cells/header_cell.vue b/ee/app/assets/javascripts/licenses/components/cells/header_cell.vue new file mode 100644 index 00000000000..4e08f6801d2 --- /dev/null +++ b/ee/app/assets/javascripts/licenses/components/cells/header_cell.vue @@ -0,0 +1,31 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import Cell from './cell.vue'; + +export default { + name: 'HeaderCell', + components: { + Icon, + Cell, + }, + props: { + title: { + type: String, + required: true, + }, + icon: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <cell class="license-header-cell" :is-flexible="false"> + <template slot="title"> + <icon class="icon" :name="icon" /> + <span class="ml-2 font-weight-bold">{{ title }}</span> + </template> + </cell> +</template> diff --git a/ee/app/assets/javascripts/licenses/components/cells/index.js b/ee/app/assets/javascripts/licenses/components/cells/index.js new file mode 100644 index 00000000000..e43752767b8 --- /dev/null +++ b/ee/app/assets/javascripts/licenses/components/cells/index.js @@ -0,0 +1,8 @@ +import Cell from './cell.vue'; +import HeaderCell from './header_cell.vue'; +import InfoCell from './info_cell.vue'; +import DateCell from './date_cell.vue'; +import SkeletonCell from './skeleton_cell.vue'; +import SkeletonHeaderCell from './skeleton_header_cell.vue'; + +export { Cell, HeaderCell, InfoCell, DateCell, SkeletonCell, SkeletonHeaderCell }; diff --git a/ee/app/assets/javascripts/licenses/components/cells/info_cell.vue b/ee/app/assets/javascripts/licenses/components/cells/info_cell.vue new file mode 100644 index 00000000000..5c5e5c2f4e1 --- /dev/null +++ b/ee/app/assets/javascripts/licenses/components/cells/info_cell.vue @@ -0,0 +1,58 @@ +<script> +import { GlPopover } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import Cell from './cell.vue'; + +export default { + name: 'InfoCell', + components: { + Icon, + GlPopover, + Cell, + }, + props: { + title: { + type: String, + required: true, + default: null, + }, + value: { + type: [Number, String], + required: false, + default: null, + }, + popoverContent: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + popoverTarget: null, + }; + }, + mounted() { + this.popoverTarget = this.$refs.popoverTarget; + }, +}; +</script> + +<template> + <cell class="license-info-cell" :value="value"> + <template slot="title"> + <span class="mr-2 text">{{ title }}</span> + + <button ref="popoverTarget" type="button" class="btn-link information-target"> + <icon name="information" css-classes="icon d-block" /> + </button> + + <gl-popover + placement="bottom" + :target="popoverTarget" + :content="popoverContent" + triggers="hover" + /> + </template> + </cell> +</template> diff --git a/ee/app/assets/javascripts/licenses/components/cells/skeleton_cell.vue b/ee/app/assets/javascripts/licenses/components/cells/skeleton_cell.vue new file mode 100644 index 00000000000..159d096cbe5 --- /dev/null +++ b/ee/app/assets/javascripts/licenses/components/cells/skeleton_cell.vue @@ -0,0 +1,19 @@ +<script> +import { GlSkeletonLoading } from '@gitlab/ui'; +import Cell from './cell.vue'; + +export default { + name: 'SkeletonCell', + components: { + Cell, + GlSkeletonLoading, + }, +}; +</script> + +<template> + <cell> + <gl-skeleton-loading slot="title" class="w-75 skeleton-bar" :lines="1" /> + <gl-skeleton-loading slot="value" class="w-50 skeleton-bar" :lines="1" /> + </cell> +</template> diff --git a/ee/app/assets/javascripts/licenses/components/cells/skeleton_header_cell.vue b/ee/app/assets/javascripts/licenses/components/cells/skeleton_header_cell.vue new file mode 100644 index 00000000000..e4f476d8ee3 --- /dev/null +++ b/ee/app/assets/javascripts/licenses/components/cells/skeleton_header_cell.vue @@ -0,0 +1,18 @@ +<script> +import { GlSkeletonLoading } from '@gitlab/ui'; +import Cell from './cell.vue'; + +export default { + name: 'SkeletonHeaderCell', + components: { + Cell, + GlSkeletonLoading, + }, +}; +</script> + +<template> + <cell class="license-header-cell" :is-flexible="false"> + <gl-skeleton-loading slot="title" class="w-75 skeleton-bar" :lines="1" /> + </cell> +</template> diff --git a/ee/app/assets/javascripts/licenses/components/license_cards_list.vue b/ee/app/assets/javascripts/licenses/components/license_cards_list.vue new file mode 100644 index 00000000000..a56d6d2216c --- /dev/null +++ b/ee/app/assets/javascripts/licenses/components/license_cards_list.vue @@ -0,0 +1,44 @@ +<script> +import { mapState, mapGetters } from 'vuex'; +import { GlButton } from '@gitlab/ui'; +import { LicenseCard, SkeletonLicenseCard } from './cards'; + +export default { + name: 'LicenseCardsList', + components: { + LicenseCard, + SkeletonLicenseCard, + GlButton, + }, + computed: { + ...mapState(['licenses', 'isLoadingLicenses', 'newLicensePath']), + ...mapGetters(['hasLicenses']), + }, +}; +</script> + +<template> + <div> + <div class="d-flex justify-content-between align-items-center"> + <h4>{{ __('Instance license') }}</h4> + + <gl-button class="my-3 js-add-license" variant="success" :href="newLicensePath"> + {{ __('Add license') }} + </gl-button> + </div> + + <ul class="license-list list-unstyled"> + <li v-if="isLoadingLicenses"> + <skeleton-license-card /> + </li> + <li v-for="(license, index) in licenses" v-else-if="hasLicenses" :key="license.id"> + <license-card :license="license" :is-current-license="index === 0" /> + </li> + <li v-else> + <strong> + {{ __('No licenses found.') }} + </strong> + </li> + </ul> + </div> +</template> diff --git a/ee/app/assets/javascripts/licenses/index.js b/ee/app/assets/javascripts/licenses/index.js new file mode 100644 index 00000000000..f712fba0bfe --- /dev/null +++ b/ee/app/assets/javascripts/licenses/index.js @@ -0,0 +1,38 @@ +import Vue from 'vue'; +import { mapActions } from 'vuex'; +import store from './store'; +import LicenseCardsList from './components/license_cards_list.vue'; + +export default function mountInstanceLicenseApp(mountElement) { + if (!mountElement) return undefined; + + const { + currentActiveUserCount, + licensesPath, + deleteLicensePath, + newLicensePath, + downloadLicensePath, + } = mountElement.dataset; + + return new Vue({ + el: mountElement, + store, + created() { + this.setInitialData({ + licensesPath, + deleteLicensePath, + newLicensePath, + downloadLicensePath, + currentActiveUserCount: parseInt(currentActiveUserCount, 10), + }); + + this.fetchLicenses(); + }, + methods: { + ...mapActions(['setInitialData', 'fetchLicenses']), + }, + render(createElement) { + return createElement(LicenseCardsList); + }, + }); +} diff --git a/ee/app/assets/javascripts/licenses/store/actions.js b/ee/app/assets/javascripts/licenses/store/actions.js new file mode 100644 index 00000000000..9b197f97c1d --- /dev/null +++ b/ee/app/assets/javascripts/licenses/store/actions.js @@ -0,0 +1,46 @@ +import axios from '~/lib/utils/axios_utils'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import * as types from './mutation_types'; +import flashMessage from './flash_message'; + +export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data); + +export const requestLicenses = ({ commit }) => commit(types.REQUEST_LICENSES); +export const receiveLicensesSuccess = ({ commit }, licenses) => + commit(types.RECEIVE_LICENSES_SUCCESS, licenses); +export const receiveLicensesError = ({ commit }) => commit(types.RECEIVE_LICENSES_ERROR); +export const fetchLicenses = ({ state, dispatch }) => { + dispatch('requestLicenses'); + + return axios + .get(state.licensesPath) + .then(({ data }) => + dispatch('receiveLicensesSuccess', convertObjectPropsToCamelCase(data, { deep: true })), + ) + .catch(({ response }) => { + flashMessage('fetchLicenses', response.status); + + dispatch('receiveLicensesError'); + }); +}; + +export const requestDeleteLicense = ({ commit }, license) => + commit(types.REQUEST_DELETE_LICENSE, license); +export const receiveDeleteLicenseSuccess = ({ commit }, license) => + commit(types.RECEIVE_DELETE_LICENSE_SUCCESS, license); +export const receiveDeleteLicenseError = ({ commit }, license) => + commit(types.RECEIVE_DELETE_LICENSE_ERROR, license); +export const fetchDeleteLicense = ({ state, dispatch }, { id }) => { + dispatch('requestDeleteLicense', { id }); + + return axios + .delete(state.deleteLicensePath.replace(':id', id)) + .then(() => dispatch('receiveDeleteLicenseSuccess', { id })) + .catch(({ response }) => { + flashMessage('fetchDeleteLicense', response.status); + + dispatch('receiveDeleteLicenseError', { id }); + }); +}; + +export default () => {}; diff --git a/ee/app/assets/javascripts/licenses/store/flash_message.js b/ee/app/assets/javascripts/licenses/store/flash_message.js new file mode 100644 index 00000000000..255712840a8 --- /dev/null +++ b/ee/app/assets/javascripts/licenses/store/flash_message.js @@ -0,0 +1,21 @@ +import createFlash from '~/flash'; +import { __ } from '~/locale'; + +const FLASH_MESSAGES = { + fetchLicenses: { + 403: __('Fetching licenses failed. You are not permitted to perform this action.'), + 404: __('Fetching licenses failed. The request endpoint was not found.'), + default: __('Fetching licenses failed.'), + }, + fetchDeleteLicense: { + 403: __('Deleting the license failed. You are not permitted to perform this action.'), + 404: __('Deleting the license failed. The license was not found.'), + default: __('Deleting the license failed.'), + }, +}; + +export default function flashMessage(action, status) { + const messages = FLASH_MESSAGES[action]; + + createFlash(messages[status] || messages.default); +} diff --git a/ee/app/assets/javascripts/licenses/store/getters.js b/ee/app/assets/javascripts/licenses/store/getters.js new file mode 100644 index 00000000000..564f14a6521 --- /dev/null +++ b/ee/app/assets/javascripts/licenses/store/getters.js @@ -0,0 +1,3 @@ +export const hasLicenses = state => state.licenses.length > 0; + +export default () => {}; diff --git a/ee/app/assets/javascripts/licenses/store/index.js b/ee/app/assets/javascripts/licenses/store/index.js new file mode 100644 index 00000000000..b7c1d4812ab --- /dev/null +++ b/ee/app/assets/javascripts/licenses/store/index.js @@ -0,0 +1,17 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import createState from './state'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export const createStore = () => + new Vuex.Store({ + state: createState(), + actions, + getters, + mutations, + }); +export default createStore(); diff --git a/ee/app/assets/javascripts/licenses/store/mutation_types.js b/ee/app/assets/javascripts/licenses/store/mutation_types.js new file mode 100644 index 00000000000..ad42b470f0d --- /dev/null +++ b/ee/app/assets/javascripts/licenses/store/mutation_types.js @@ -0,0 +1,9 @@ +export const SET_INITIAL_DATA = 'SET_INITIAL_DATA'; + +export const REQUEST_LICENSES = 'REQUEST_LICENSES'; +export const RECEIVE_LICENSES_SUCCESS = 'RECEIVE_LICENSES_SUCCESS'; +export const RECEIVE_LICENSES_ERROR = 'RECEIVE_LICENSES_ERROR'; + +export const REQUEST_DELETE_LICENSE = 'REQUEST_DELETE_LICENSE'; +export const RECEIVE_DELETE_LICENSE_SUCCESS = 'RECEIVE_DELETE_LICENSE_SUCCESS'; +export const RECEIVE_DELETE_LICENSE_ERROR = 'RECEIVE_DELETE_LICENSE_ERROR'; diff --git a/ee/app/assets/javascripts/licenses/store/mutations.js b/ee/app/assets/javascripts/licenses/store/mutations.js new file mode 100644 index 00000000000..9e7d22dcf5a --- /dev/null +++ b/ee/app/assets/javascripts/licenses/store/mutations.js @@ -0,0 +1,41 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_INITIAL_DATA](state, data) { + Object.assign(state, data); + }, + + [types.REQUEST_LICENSES](state) { + state.isLoadingLicenses = true; + }, + + [types.RECEIVE_LICENSES_SUCCESS](state, licenses = []) { + state.isLoadingLicenses = false; + + state.licenses = licenses; + }, + + [types.RECEIVE_LICENSES_ERROR](state) { + state.isLoadingLicenses = false; + }, + + [types.REQUEST_DELETE_LICENSE](state, { id }) { + if (state.deleteQueue.includes(id)) return; + + state.deleteQueue.push(id); + }, + + [types.RECEIVE_DELETE_LICENSE_SUCCESS](state, { id }) { + const queueIndex = state.deleteQueue.indexOf(id); + const licenseIndex = state.licenses.findIndex(license => id === license.id); + + if (queueIndex !== -1) state.deleteQueue.splice(queueIndex, 1); + if (licenseIndex !== -1) state.licenses.splice(licenseIndex, 1); + }, + + [types.RECEIVE_DELETE_LICENSE_ERROR](state, { id }) { + const queueIndex = state.deleteQueue.indexOf(id); + + if (queueIndex !== -1) state.deleteQueue.splice(queueIndex, 1); + }, +}; diff --git a/ee/app/assets/javascripts/licenses/store/state.js b/ee/app/assets/javascripts/licenses/store/state.js new file mode 100644 index 00000000000..8ef3ce6b5e2 --- /dev/null +++ b/ee/app/assets/javascripts/licenses/store/state.js @@ -0,0 +1,10 @@ +export default () => ({ + licenses: [], + deleteQueue: [], + isLoadingLicenses: false, + licensesPath: '', + deleteLicensePath: '', + newLicensePath: '', + downloadLicensePath: '', + currentActiveUserCount: null, +}); diff --git a/ee/app/assets/javascripts/pages/admin/licenses/show/index.js b/ee/app/assets/javascripts/pages/admin/licenses/show/index.js new file mode 100644 index 00000000000..4ea63128bd2 --- /dev/null +++ b/ee/app/assets/javascripts/pages/admin/licenses/show/index.js @@ -0,0 +1,6 @@ +import mountInstanceLicenseApp from 'ee/licenses'; + +document.addEventListener('DOMContentLoaded', () => { + const mountElement = document.getElementById('instance-license-mount-element'); + mountInstanceLicenseApp(mountElement); +}); diff --git a/ee/app/assets/stylesheets/pages/licenses.scss b/ee/app/assets/stylesheets/pages/licenses.scss index 4f4aac5a6a4..8aecc0c06e3 100644 --- a/ee/app/assets/stylesheets/pages/licenses.scss +++ b/ee/app/assets/stylesheets/pages/licenses.scss @@ -5,3 +5,64 @@ color: $gl-gray-light; } } + +.license-card-body { + overflow-x: scroll; + + @include media-breakpoint-up(lg) { + overflow-x: hidden; + } +} + +.license-table { + min-width: max-content; + + .license-row:first-child .license-cell { + border-top: 0; + } + + .license-cell:last-child { + border-right: 0; + } +} + +.license-cell { + border-color: $gray-200; + border-style: solid; + border-width: 1px 1px 0 0; + flex-basis: 0; + + .title { + color: $gray-700; + line-height: $gl-line-height; + } + + .value { + color: $gray-900; + + &.number { + font-size: 1.25rem; + } + } +} + +.license-header-cell { + flex-basis: initial; + width: $license-header-cell-width; + + .title { + color: $gray-900; + } +} + +.skeleton-license-card { + .skeleton-bar { + max-height: $gl-line-height; + + .skeleton-line-1, + .skeleton-line-1::after { + height: 100%; + width: 100%; + } + } +} diff --git a/ee/app/helpers/license_helper.rb b/ee/app/helpers/license_helper.rb index c34b4dad995..a824877163d 100644 --- a/ee/app/helpers/license_helper.rb +++ b/ee/app/helpers/license_helper.rb @@ -110,5 +110,11 @@ module LicenseHelper !Gitlab::CurrentSettings.should_check_namespace_plan? && show_promotions? && show_callout?('promote_advanced_search_dismissed') && !License.feature_available?(:elastic_search) end + def license_app_data + { data: { current_active_user_count: current_active_user_count, + licenses_path: api_v4_licenses_path, delete_license_path: api_v4_license_path(id: ':id'), + new_license_path: new_admin_license_path, download_license_path: download_admin_license_path } } + end + extend self end diff --git a/ee/app/views/admin/licenses/show.html.haml b/ee/app/views/admin/licenses/show.html.haml index 86a98374841..b10ecad53ab 100644 --- a/ee/app/views/admin/licenses/show.html.haml +++ b/ee/app/views/admin/licenses/show.html.haml @@ -1,112 +1,116 @@ - page_title "License" -%h3.page-title - Your License - - if current_license.trial? - = render "upload_buy_license" - - else - = link_to 'Upload New License', new_admin_license_path, class: "btn btn-success float-right" -%hr +- if Feature.enabled?(:licenses_app) + #instance-license-mount-element{ license_app_data } +- else + %h3.page-title + Your License + - if current_license.trial? + = render "upload_buy_license" + - else + = link_to 'Upload New License', new_admin_license_path, class: "btn btn-success float-right" -.row - .col-md-6 - .card - .card-header - Licensed to - %ul.content-list - - @license.licensee.each do |label, value| - %li - %span.light #{label}: - %strong= value + %hr - .card.js-license-info-panel - .card-header - Details - %ul.content-list - %li - %span.light Plan: - %strong= @license.plan.capitalize - %li - %span.light Uploaded: - %strong= time_ago_with_tooltip @license.created_at - %li - %span.light Started: - %strong= time_ago_with_tooltip @license.starts_at - %li - %span.light - - if @license.expired? - Expired: - - else - Expires: - - if @license.will_expire? && @license.active? - - if @license.trial? - %strong.has-tooltip{ title: @license.expires_at.to_formatted_s(:long), data: { placement: 'top' } } - Free trial will expire in #{pluralize(@license.remaining_days, 'day')} + .row + .col-md-6 + .card + .card-header + Licensed to + %ul.content-list + - @license.licensee.each do |label, value| + %li + %span.light #{label}: + %strong= value + + .card.js-license-info-panel + .card-header + Details + %ul.content-list + %li + %span.light Plan: + %strong= @license.plan.capitalize + %li + %span.light Uploaded: + %strong= time_ago_with_tooltip @license.created_at + %li + %span.light Started: + %strong= time_ago_with_tooltip @license.starts_at + %li + %span.light + - if @license.expired? + Expired: + - else + Expires: + - if @license.will_expire? && @license.active? + - if @license.trial? + %strong.has-tooltip{ title: @license.expires_at.to_formatted_s(:long), data: { placement: 'top' } } + Free trial will expire in #{pluralize(@license.remaining_days, 'day')} + - else + %strong= time_ago_with_tooltip(@license.expires_at) - else - %strong= time_ago_with_tooltip(@license.expires_at) - - else - %strong Never + %strong Never - - if @license.expired? - %span.badge.badge-danger.float-right - %strong Expired + - if @license.expired? + %span.badge.badge-danger.float-right + %strong Expired - .col-md-6 - .card.border-info - .card-header.bg-info.text-white - Download license - .card-body - %p Your license will be included in your GitLab backup and will survive upgrades, so in normal usage you should never need to re-upload your <code>.gitlab-license</code> file. - %p Still, we recommend keeping a backup saved somewhere. Otherwise, if you ever need it and have lost it, you will need to request GitLab Inc. to send it to you again. - %br - = link_to 'Download license', download_admin_license_path, class: "btn btn-info" + .col-md-6 + .card.border-info + .card-header.bg-info.text-white + Download license + .card-body + %p Your license will be included in your GitLab backup and will survive upgrades, so in normal usage you should never need to re-upload your <code>.gitlab-license</code> file. + %p Still, we recommend keeping a backup saved somewhere. Otherwise, if you ever need it and have lost it, you will need to request GitLab Inc. to send it to you again. + %br + = link_to 'Download license', download_admin_license_path, class: "btn btn-info" - .card.border-danger - .card-header.bg-danger.text-white - Remove license - .card-body - %p If you remove this license, GitLab will fall back on the previous license, if any. - %p If there is no previous license or if the previous license has expired, some GitLab functionality will be blocked until a new, valid license is uploaded. - %br - = link_to 'Remove license', admin_license_path, data: { confirm: "Are you sure you want to remove the license?" }, method: :delete, class: "btn btn-remove qa-remove-license-link" + .card.border-danger + .card-header.bg-danger.text-white + Remove license + .card-body + %p If you remove this license, GitLab will fall back on the previous license, if any. + %p If there is no previous license or if the previous license has expired, some GitLab functionality will be blocked until a new, valid license is uploaded. + %br + = link_to 'Remove license', admin_license_path, data: { confirm: "Are you sure you want to remove the license?" }, method: :delete, class: "btn btn-remove qa-remove-license-link" -= render "breakdown", license: @license + = render "breakdown", license: @license -- if @previous_licenses.any? - %h4 License History + - if @previous_licenses.any? + %h4 License History - .card#license_history - %table.table - %thead.card-header - %tr - - @license.licensee.keys.each do |label| - %th= label - %th Plan - %th Uploaded at - %th Started at - %th Expired at - %th Active users - %tbody - - @previous_licenses.each do |license| + .card#license_history + %table.table + %thead.card-header %tr - @license.licensee.keys.each do |label| - %td= license.licensee[label] - %td - %span - = license.plan.capitalize - %td - %span - = license.created_at - %td - %span - = license.starts_at - %td - %span - = license.expires_at || "Never" - %td - %span - - if license.restricted?(:active_user_count) - #{license.restrictions[:active_user_count]} users - - else - Unlimited + %th= label + %th Plan + %th Uploaded at + %th Started at + %th Expired at + %th Active users + %tbody + - @previous_licenses.each do |license| + %tr + - @license.licensee.keys.each do |label| + %td= license.licensee[label] + %td + %span + = license.plan.capitalize + %td + %span + = license.created_at + %td + %span + = license.starts_at + %td + %span + = license.expires_at || "Never" + %td + %span + - if license.restricted?(:active_user_count) + #{license.restrictions[:active_user_count]} users + - else + Unlimited diff --git a/ee/changelogs/unreleased/improve-admin-licence-page-rest-fe.yml b/ee/changelogs/unreleased/improve-admin-licence-page-rest-fe.yml new file mode 100644 index 00000000000..b8a7baeb6a5 --- /dev/null +++ b/ee/changelogs/unreleased/improve-admin-licence-page-rest-fe.yml @@ -0,0 +1,5 @@ +--- +title: Simplify admin instance licenses page +merge_request: 9785 +author: +type: other diff --git a/ee/spec/features/admin/licenses/admin_uploads_license_spec.rb b/ee/spec/features/admin/licenses/admin_uploads_license_spec.rb index 33c84d8ee31..cc730639e86 100644 --- a/ee/spec/features/admin/licenses/admin_uploads_license_spec.rb +++ b/ee/spec/features/admin/licenses/admin_uploads_license_spec.rb @@ -4,6 +4,7 @@ describe "Admin uploads license" do set(:admin) { create(:admin) } before do + stub_feature_flags(licenses_app: false) sign_in(admin) end diff --git a/ee/spec/features/admin/licenses/admin_views_license_spec.rb b/ee/spec/features/admin/licenses/admin_views_license_spec.rb index ce50250efda..0e46db5bc94 100644 --- a/ee/spec/features/admin/licenses/admin_views_license_spec.rb +++ b/ee/spec/features/admin/licenses/admin_views_license_spec.rb @@ -4,6 +4,7 @@ describe "Admin views license" do set(:admin) { create(:admin) } before do + stub_feature_flags(licenses_app: false) sign_in(admin) end diff --git a/ee/spec/features/admin/licenses/licenses_app_spec.rb b/ee/spec/features/admin/licenses/licenses_app_spec.rb new file mode 100644 index 00000000000..b75898249b0 --- /dev/null +++ b/ee/spec/features/admin/licenses/licenses_app_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Licenses app", :js do + let(:admin) { create(:admin) } + let!(:licenses) do + [ + create(:license, data: build(:gitlab_license, restrictions: { active_user_count: 2000 }).export), + create(:license, data: build(:gitlab_license, expires_at: Date.today - 10, restrictions: { active_user_count: 2000, plan: 'ultimate' }).export) + ] + end + + def visit_page + visit(admin_license_path) + + find('.js-license-table', match: :first) + end + + def assert_usage_row(row, license) + header, seats_in_license, seats_in_use, historical_max, overage = row.find_all('.license-cell').to_a + + expect(header).to have_content 'Usage' + expect(seats_in_license).to have_content 'Seats in license' + expect(seats_in_license).to have_content license.restrictions[:active_user_count] + expect(seats_in_use).to have_content 'Seats currently in use' + expect(seats_in_use).to have_content User.active.count + expect(historical_max).to have_content 'Max seats used' + expect(historical_max).to have_content license.historical_max + expect(overage).to have_content 'Users outside of license' + expect(overage).to have_content license.overage + end + + def assert_validity_row(row, license) + header, starts_at, expires_at, created_at = row.find_all('.license-cell').to_a + + expect(header).to have_content 'Validity' + expect(starts_at).to have_content 'Start date' + expect(starts_at).to have_content license.starts_at.strftime('%B %-d, %Y') + expect(expires_at).to have_content 'End date' + expect(expires_at).to have_content license.expires_at.strftime('%B %-d, %Y') + + if license.expired? + expect(expires_at).to have_content 'Expired' + else + expect(expires_at).not_to have_content 'Expired' + end + + expect(created_at).to have_content 'Uploaded on' + expect(created_at).to have_content license.created_at.strftime('%B %-d, %Y') + end + + def assert_registration_row(row, license) + header, name, email, company = row.find_all('.license-cell').to_a + + expect(header).to have_content 'Registration' + expect(name).to have_content 'Licensed to' + expect(name).to have_content license.licensee['Name'] || 'Unknown' + expect(email).to have_content 'Email address' + expect(email).to have_content license.licensee['Email'] || 'Unknown' + expect(company).to have_content 'Company' + expect(company).to have_content license.licensee['Company'] || 'Unknown' + end + + def assert_license_card(card, license) + top_row, middle_row, bottom_row = card.find_all('.license-row').to_a + + assert_usage_row(top_row, license) + assert_validity_row(middle_row, license) + assert_registration_row(bottom_row, license) + end + + before do + stub_feature_flags(licenses_app: true) + sign_in(admin) + end + + it 'renders a list of licenses' do + visit_page + + licenses.each_with_index do |license, index| + assert_license_card(find_all('.license-table')[index], licenses.reverse[index]) + end + end + + it 'deletes a license' do + visit_page + + license_card = find('.license-card', match: :first) + current_id = License.current.id + + license_card.find('.js-manage-license').click + + page.accept_alert 'Are you sure you want to permanently delete this license?' do + license_card.find('.js-delete-license').click + end + + expect(license_card).not_to have_selector('.license-card-loading') + expect(License.find_by(id: current_id)).to be_nil + end +end diff --git a/ee/spec/frontend/licenses/components/__snapshots__/license_cards_list_spec.js.snap b/ee/spec/frontend/licenses/components/__snapshots__/license_cards_list_spec.js.snap new file mode 100644 index 00000000000..c848ec013aa --- /dev/null +++ b/ee/spec/frontend/licenses/components/__snapshots__/license_cards_list_spec.js.snap @@ -0,0 +1,103 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InstanceCardsList renders a list of license cards 1`] = ` +<div> + <div + class="d-flex justify-content-between align-items-center" + > + <h4> + Instance license + </h4> + + <glbutton-stub + class="my-3 js-add-license" + href="/newLicensePath" + variant="success" + > + + Add license + + </glbutton-stub> + </div> + + <ul + class="license-list list-unstyled" + > + <li> + <licensecard-stub + iscurrentlicense="true" + license="[object Object]" + /> + </li> + <li> + <licensecard-stub + license="[object Object]" + /> + </li> + </ul> +</div> +`; + +exports[`InstanceCardsList renders a message when there are no licenses 1`] = ` +<div> + <div + class="d-flex justify-content-between align-items-center" + > + <h4> + Instance license + </h4> + + <glbutton-stub + class="my-3 js-add-license" + href="/newLicensePath" + variant="success" + > + + Add license + + </glbutton-stub> + </div> + + <ul + class="license-list list-unstyled" + > + <li> + <strong> + + No licenses found. + + </strong> + </li> + </ul> +</div> +`; + +exports[`InstanceCardsList renders a skeleton loading card if loading licenses 1`] = ` +<div> + <div + class="d-flex justify-content-between align-items-center" + > + <h4> + Instance license + </h4> + + <glbutton-stub + class="my-3 js-add-license" + href="/newLicensePath" + variant="success" + > + + Add license + + </glbutton-stub> + </div> + + <ul + class="license-list list-unstyled" + > + <li> + <skeletonlicensecard-stub /> + </li> + </ul> +</div> +`; diff --git a/ee/spec/frontend/licenses/components/cards/__snapshots__/license_card_body_spec.js.snap b/ee/spec/frontend/licenses/components/cards/__snapshots__/license_card_body_spec.js.snap new file mode 100644 index 00000000000..5f803f48f20 --- /dev/null +++ b/ee/spec/frontend/licenses/components/cards/__snapshots__/license_card_body_spec.js.snap @@ -0,0 +1,217 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LicenseCardBody renders a license card body 1`] = ` +<div + class="card-body license-card-body p-0" +> + <div + class="license-table js-license-table" + > + <div + class="license-row d-flex" + > + <headercell-stub + icon="monitor" + title="Usage" + /> + + <cell-stub + isflexible="true" + title="Seats in license" + value="10" + /> + + <infocell-stub + popovercontent="Users with a Guest role or those who don't belong to any projects or groups don't count towards seats in use." + title="Seats currently in use" + value="10" + /> + + <infocell-stub + popovercontent="This is the maximum number of users that have existed at the same time since the license started. This is the minimum number of seats you will need to buy when you renew your license." + title="Max seats used" + value="20" + /> + + <infocell-stub + popovercontent="GitLab allows you to continue using your license even if you exceed the number of seats you purchased. You will be required to pay for these seats when you renew your license." + title="Users outside of license" + value="5" + /> + </div> + + <div + class="license-row d-flex" + > + <headercell-stub + icon="calendar" + title="Validity" + /> + + <datecell-stub + datenow="2017/10/10" + title="Start date" + value="2013/10/10" + /> + + <datecell-stub + datenow="2017/10/10" + isexpirable="true" + title="End date" + value="2015/10/10" + /> + + <datecell-stub + datenow="2017/10/10" + title="Uploaded on" + /> + </div> + + <div + class="license-row d-flex" + > + <headercell-stub + icon="user" + title="Registration" + /> + + <cell-stub + isflexible="true" + title="Licensed to" + value="Jon Dough" + /> + + <cell-stub + isflexible="true" + title="Email address" + value="email@address.tanuki" + /> + + <cell-stub + isflexible="true" + title="Company" + value="TanukiVille" + /> + </div> + </div> +</div> +`; + +exports[`LicenseCardBody renders a loading state if isRemoving 1`] = ` +<div + class="card-body license-card-body p-0" +> + <div + class="p-5 d-flex justify-content-center align-items-center license-card-loading" + > + <icon-stub + cssclasses="" + name="spinner" + size="16" + /> + <span + class="ml-2" + > + Removing license… + </span> + </div> +</div> +`; + +exports[`LicenseCardBody renders fallback licensee values 1`] = ` +<div + class="card-body license-card-body p-0" + licensee="[object Object]" +> + <div + class="license-table js-license-table" + > + <div + class="license-row d-flex" + > + <headercell-stub + icon="monitor" + title="Usage" + /> + + <cell-stub + isflexible="true" + title="Seats in license" + value="10" + /> + + <infocell-stub + popovercontent="Users with a Guest role or those who don't belong to any projects or groups don't count towards seats in use." + title="Seats currently in use" + value="10" + /> + + <infocell-stub + popovercontent="This is the maximum number of users that have existed at the same time since the license started. This is the minimum number of seats you will need to buy when you renew your license." + title="Max seats used" + value="20" + /> + + <infocell-stub + popovercontent="GitLab allows you to continue using your license even if you exceed the number of seats you purchased. You will be required to pay for these seats when you renew your license." + title="Users outside of license" + value="5" + /> + </div> + + <div + class="license-row d-flex" + > + <headercell-stub + icon="calendar" + title="Validity" + /> + + <datecell-stub + datenow="2017/10/10" + title="Start date" + value="2013/10/10" + /> + + <datecell-stub + datenow="2017/10/10" + isexpirable="true" + title="End date" + value="2015/10/10" + /> + + <datecell-stub + datenow="2017/10/10" + title="Uploaded on" + /> + </div> + + <div + class="license-row d-flex" + > + <headercell-stub + icon="user" + title="Registration" + /> + + <cell-stub + isflexible="true" + title="Licensed to" + value="Jon Dough" + /> + + <cell-stub + isflexible="true" + title="Email address" + value="email@address.tanuki" + /> + + <cell-stub + isflexible="true" + title="Company" + value="TanukiVille" + /> + </div> + </div> +</div> +`; diff --git a/ee/spec/frontend/licenses/components/cards/__snapshots__/license_card_spec.js.snap b/ee/spec/frontend/licenses/components/cards/__snapshots__/license_card_spec.js.snap new file mode 100644 index 00000000000..ffe8e9e2de0 --- /dev/null +++ b/ee/spec/frontend/licenses/components/cards/__snapshots__/license_card_spec.js.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LicenseCard renders license card with a delete button and license body 1`] = ` +<div + class="card license-card mb-5" +> + <div + class="card-header" + > + <div + class="d-flex justify-content-between align-items-center" + > + <h4> + + GitLab Enterprise Edition Super duper + + </h4> + + <gldropdown-stub + class="js-manage-license" + right="" + text="Manage" + > + <!----> + + <gldropdownitem-stub + class="js-delete-license text-danger" + > + + Delete license + + </gldropdownitem-stub> + </gldropdown-stub> + </div> + </div> + + <licensecardbody-stub + currentactiveusercount="10" + license="[object Object]" + /> +</div> +`; diff --git a/ee/spec/frontend/licenses/components/cards/__snapshots__/skeleton_license_card_spec.js.snap b/ee/spec/frontend/licenses/components/cards/__snapshots__/skeleton_license_card_spec.js.snap new file mode 100644 index 00000000000..c400f5b3d83 --- /dev/null +++ b/ee/spec/frontend/licenses/components/cards/__snapshots__/skeleton_license_card_spec.js.snap @@ -0,0 +1,62 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SkeletonLicenseCard renders a skeleton license card 1`] = ` +<div + class="card license-card skeleton-license-card" +> + <div + class="card-header d-flex justify-content-between align-items-center py-3" + > + <glskeletonloading-stub + class="w-75 skeleton-bar" + lines="1" + /> + </div> + + <div + class="card-body p-0" + > + <div + class="license-table" + > + <div + class="license-row d-flex" + > + <skeletonheadercell-stub /> + + <skeletoncell-stub /> + + <skeletoncell-stub /> + + <skeletoncell-stub /> + + <skeletoncell-stub /> + </div> + + <div + class="license-row d-flex" + > + <skeletonheadercell-stub /> + + <skeletoncell-stub /> + + <skeletoncell-stub /> + + <skeletoncell-stub /> + </div> + + <div + class="license-row d-flex" + > + <skeletonheadercell-stub /> + + <skeletoncell-stub /> + + <skeletoncell-stub /> + + <skeletoncell-stub /> + </div> + </div> + </div> +</div> +`; diff --git a/ee/spec/frontend/licenses/components/cards/license_card_body_spec.js b/ee/spec/frontend/licenses/components/cards/license_card_body_spec.js new file mode 100644 index 00000000000..9c4af8f5b1d --- /dev/null +++ b/ee/spec/frontend/licenses/components/cards/license_card_body_spec.js @@ -0,0 +1,57 @@ +import { shallowMount } from '@vue/test-utils'; +import LicenseCardBody from 'ee/licenses/components/cards/license_card_body.vue'; + +describe('LicenseCardBody', () => { + let wrapper; + const defaultProps = { + license: { + userLimit: 10, + historicalMax: 20, + overage: 5, + startsAt: '2013/10/10', + expiresAt: '2015/10/10', + licensee: { + Name: 'Jon Dough', + Email: 'email@address.tanuki', + Company: 'TanukiVille', + }, + }, + isRemoving: false, + currentActiveUserCount: 10, + }; + + function createComponent(props) { + const propsData = Object.assign({}, defaultProps, props); + + wrapper = shallowMount(LicenseCardBody, { + propsData, + }); + } + + beforeEach(() => { + jest.spyOn(global.Date.prototype, 'toString').mockReturnValue('2017/10/10'); + }); + + afterEach(() => { + if (wrapper) wrapper.destroy(); + global.Date.prototype.toString.mockRestore(); + }); + + it('renders a license card body', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders a loading state if isRemoving', () => { + createComponent({ isRemoving: true }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders fallback licensee values', () => { + createComponent({ licensee: {} }); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/ee/spec/frontend/licenses/components/cards/license_card_spec.js b/ee/spec/frontend/licenses/components/cards/license_card_spec.js new file mode 100644 index 00000000000..84b80522151 --- /dev/null +++ b/ee/spec/frontend/licenses/components/cards/license_card_spec.js @@ -0,0 +1,50 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { LicenseCard } from 'ee/licenses/components/cards'; + +describe('LicenseCard', () => { + let wrapper; + let actions; + const defaultProps = { + license: { + id: 1, + plan: 'super duper', + }, + isCurrentLicense: false, + }; + const defaultState = { + currentActiveUserCount: 10, + deleteQueue: [], + downloadLicensePath: '/downloadLicensePath', + }; + + const localVue = createLocalVue(); + localVue.use(Vuex); + + function createStore(newState) { + const state = Object.assign({}, defaultState, newState); + actions = { fetchDeleteLicense: jest.fn() }; + + return new Vuex.Store({ state, actions }); + } + + function createComponent(state, props) { + const propsData = Object.assign({}, defaultProps, props); + + wrapper = shallowMount(LicenseCard, { + store: createStore(state), + propsData, + localVue, + }); + } + + afterEach(() => { + if (wrapper) wrapper.destroy(); + }); + + it('renders license card with a delete button and license body', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/ee/spec/frontend/licenses/components/cards/skeleton_license_card_spec.js b/ee/spec/frontend/licenses/components/cards/skeleton_license_card_spec.js new file mode 100644 index 00000000000..6044636d16c --- /dev/null +++ b/ee/spec/frontend/licenses/components/cards/skeleton_license_card_spec.js @@ -0,0 +1,20 @@ +import { shallowMount } from '@vue/test-utils'; +import { SkeletonLicenseCard } from 'ee/licenses/components/cards'; + +describe('SkeletonLicenseCard', () => { + let wrapper; + + function createComponent() { + wrapper = shallowMount(SkeletonLicenseCard); + } + + afterEach(() => { + if (wrapper) wrapper.destroy(); + }); + + it('renders a skeleton license card', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/ee/spec/frontend/licenses/components/cells/__snapshots__/cell_spec.js.snap b/ee/spec/frontend/licenses/components/cells/__snapshots__/cell_spec.js.snap new file mode 100644 index 00000000000..f0bf0d0e324 --- /dev/null +++ b/ee/spec/frontend/licenses/components/cells/__snapshots__/cell_spec.js.snap @@ -0,0 +1,89 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Cell renders a number value and title through props 1`] = ` +<div + class="license-cell p-3 text-nowrap flex-shrink-0 flex-grow-1" +> + <span + class="title d-flex align-items-center justify-content-start" + > + <span> + title + </span> + </span> + + <div + class="value mt-2 number" + > + <span> + 100 + </span> + </div> +</div> +`; + +exports[`Cell renders a string value and title through props 1`] = ` +<div + class="license-cell p-3 text-nowrap flex-shrink-0 flex-grow-1" +> + <span + class="title d-flex align-items-center justify-content-start" + > + <span> + title + </span> + </span> + + <div + class="value mt-2" + > + <span> + value + </span> + </div> +</div> +`; + +exports[`Cell renders an inflexible variant 1`] = ` +<div + class="license-cell p-3 text-nowrap flex-shrink-0" +> + <span + class="title d-flex align-items-center justify-content-start" + > + <span> + title + </span> + </span> + + <div + class="value mt-2" + > + <span> + value + </span> + </div> +</div> +`; + +exports[`Cell renders value and title slots that override props 1`] = ` +<div + class="license-cell p-3 text-nowrap flex-shrink-0 flex-grow-1" +> + <span + class="title d-flex align-items-center justify-content-start" + > + <h1> + tanuki + </h1> + </span> + + <div + class="value mt-2" + > + <marquee> + party + </marquee> + </div> +</div> +`; diff --git a/ee/spec/frontend/licenses/components/cells/__snapshots__/date_cell_spec.js.snap b/ee/spec/frontend/licenses/components/cells/__snapshots__/date_cell_spec.js.snap new file mode 100644 index 00000000000..91785c34a3a --- /dev/null +++ b/ee/spec/frontend/licenses/components/cells/__snapshots__/date_cell_spec.js.snap @@ -0,0 +1,79 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DateCell renders a date value that represents a date in words and title through props 1`] = ` +<cell-stub + isflexible="true" + title="title" +> + <div + class="" + > + + March 6, 2018 + + <!----> + </div> +</cell-stub> +`; + +exports[`DateCell renders a fallback value if isExpirable and no value 1`] = ` +<cell-stub + isflexible="true" + title="title" + value="Never" +> + <!----> +</cell-stub> +`; + +exports[`DateCell renders a string value that represents a date in words and title through props 1`] = ` +<cell-stub + isflexible="true" + title="title" +> + <div + class="" + > + + October 24, 2018 + + <!----> + </div> +</cell-stub> +`; + +exports[`DateCell renders an expired warning if isExpirable and date value is before now 1`] = ` +<cell-stub + isflexible="true" + title="title" + value="Never" +> + <div + class="text-danger" + > + + October 24, 2018 + + <span> + - Expired + </span> + </div> +</cell-stub> +`; + +exports[`DateCell renders date value with no warning if isExpirable and date value is after now 1`] = ` +<cell-stub + isflexible="true" + title="title" + value="Never" +> + <div + class="" + > + + October 24, 2018 + + <!----> + </div> +</cell-stub> +`; diff --git a/ee/spec/frontend/licenses/components/cells/__snapshots__/header_cell_spec.js.snap b/ee/spec/frontend/licenses/components/cells/__snapshots__/header_cell_spec.js.snap new file mode 100644 index 00000000000..d44c28fe76b --- /dev/null +++ b/ee/spec/frontend/licenses/components/cells/__snapshots__/header_cell_spec.js.snap @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HeaderCell renders an inflexible cell with a title with an icon through props 1`] = ` +<cell-stub + class="license-header-cell" +> + <template> + <icon-stub + class="icon" + cssclasses="" + name="retry" + size="16" + /> + + <span + class="ml-2 font-weight-bold" + > + title + </span> + </template> +</cell-stub> +`; diff --git a/ee/spec/frontend/licenses/components/cells/__snapshots__/info_cell_spec.js.snap b/ee/spec/frontend/licenses/components/cells/__snapshots__/info_cell_spec.js.snap new file mode 100644 index 00000000000..00cec1d439d --- /dev/null +++ b/ee/spec/frontend/licenses/components/cells/__snapshots__/info_cell_spec.js.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InfoCell renders a number value 1`] = ` +<cell-stub + class="license-info-cell" + isflexible="true" + value="100" +> + <template> + <span + class="mr-2 text" + > + title + </span> + + <button + class="btn-link information-target" + type="button" + > + <icon-stub + cssclasses="icon d-block" + name="information" + size="16" + /> + </button> + + <glpopover-stub + content="popoverContent" + placement="bottom" + triggers="hover" + /> + </template> +</cell-stub> +`; + +exports[`InfoCell renders a title and string value with an info popover through props 1`] = ` +<cell-stub + class="license-info-cell" + isflexible="true" + value="value" +> + <template> + <span + class="mr-2 text" + > + title + </span> + + <button + class="btn-link information-target" + type="button" + > + <icon-stub + cssclasses="icon d-block" + name="information" + size="16" + /> + </button> + + <glpopover-stub + content="popoverContent" + placement="bottom" + triggers="hover" + /> + </template> +</cell-stub> +`; diff --git a/ee/spec/frontend/licenses/components/cells/__snapshots__/skeleton_cell_spec.js.snap b/ee/spec/frontend/licenses/components/cells/__snapshots__/skeleton_cell_spec.js.snap new file mode 100644 index 00000000000..ef35d5cdc7f --- /dev/null +++ b/ee/spec/frontend/licenses/components/cells/__snapshots__/skeleton_cell_spec.js.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SkeletonCell renders a skeleton cell with a title and value loading bar 1`] = ` +<cell-stub + isflexible="true" +> + <glskeletonloading-stub + class="w-75 skeleton-bar" + lines="1" + /> + + <glskeletonloading-stub + class="w-50 skeleton-bar" + lines="1" + /> +</cell-stub> +`; diff --git a/ee/spec/frontend/licenses/components/cells/__snapshots__/skeleton_header_cell_spec.js.snap b/ee/spec/frontend/licenses/components/cells/__snapshots__/skeleton_header_cell_spec.js.snap new file mode 100644 index 00000000000..2f2511f7093 --- /dev/null +++ b/ee/spec/frontend/licenses/components/cells/__snapshots__/skeleton_header_cell_spec.js.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SkeletonHeaderCell renders a skeleton cell with a single title loading bar 1`] = ` +<cell-stub + class="license-header-cell" +> + <glskeletonloading-stub + class="w-75 skeleton-bar" + lines="1" + /> +</cell-stub> +`; diff --git a/ee/spec/frontend/licenses/components/cells/cell_spec.js b/ee/spec/frontend/licenses/components/cells/cell_spec.js new file mode 100644 index 00000000000..63a62b9df2a --- /dev/null +++ b/ee/spec/frontend/licenses/components/cells/cell_spec.js @@ -0,0 +1,47 @@ +import { shallowMount } from '@vue/test-utils'; +import { Cell } from 'ee/licenses/components/cells'; + +describe('Cell', () => { + let wrapper; + const defaultProps = { + title: 'title', + value: 'value', + }; + + function createComponent(props, slots) { + const propsData = Object.assign({}, defaultProps, props); + + wrapper = shallowMount(Cell, { + propsData, + slots, + }); + } + + afterEach(() => { + if (wrapper) wrapper.destroy(); + }); + + it('renders a string value and title through props', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders a number value and title through props', () => { + createComponent({ value: 100 }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders value and title slots that override props', () => { + createComponent(null, { title: '<h1>tanuki</h1>', value: '<marquee>party</marquee>' }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders an inflexible variant', () => { + createComponent({ isFlexible: false }); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/ee/spec/frontend/licenses/components/cells/date_cell_spec.js b/ee/spec/frontend/licenses/components/cells/date_cell_spec.js new file mode 100644 index 00000000000..45a910717ea --- /dev/null +++ b/ee/spec/frontend/licenses/components/cells/date_cell_spec.js @@ -0,0 +1,52 @@ +import { shallowMount } from '@vue/test-utils'; +import { DateCell } from 'ee/licenses/components/cells'; + +describe('DateCell', () => { + let wrapper; + const defaultProps = { + title: 'title', + value: '2018/10/24', + }; + + function createComponent(props) { + const propsData = Object.assign({}, defaultProps, props); + + wrapper = shallowMount(DateCell, { + propsData, + }); + } + + afterEach(() => { + if (wrapper) wrapper.destroy(); + }); + + it('renders a string value that represents a date in words and title through props', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders a date value that represents a date in words and title through props', () => { + createComponent({ value: new Date('2018/03/06') }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders an expired warning if isExpirable and date value is before now', () => { + createComponent({ isExpirable: true }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders date value with no warning if isExpirable and date value is after now', () => { + createComponent({ isExpirable: true, dateNow: new Date('2017/10/10') }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders a fallback value if isExpirable and no value', () => { + createComponent({ isExpirable: true, value: undefined }); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/ee/spec/frontend/licenses/components/cells/header_cell_spec.js b/ee/spec/frontend/licenses/components/cells/header_cell_spec.js new file mode 100644 index 00000000000..07ba898b6ae --- /dev/null +++ b/ee/spec/frontend/licenses/components/cells/header_cell_spec.js @@ -0,0 +1,25 @@ +import { shallowMount } from '@vue/test-utils'; +import { HeaderCell } from 'ee/licenses/components/cells'; + +describe('HeaderCell', () => { + let wrapper; + + function createComponent() { + wrapper = shallowMount(HeaderCell, { + propsData: { + title: 'title', + icon: 'retry', + }, + }); + } + + afterEach(() => { + if (wrapper) wrapper.destroy(); + }); + + it('renders an inflexible cell with a title with an icon through props', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/ee/spec/frontend/licenses/components/cells/info_cell_spec.js b/ee/spec/frontend/licenses/components/cells/info_cell_spec.js new file mode 100644 index 00000000000..6601a33e3de --- /dev/null +++ b/ee/spec/frontend/licenses/components/cells/info_cell_spec.js @@ -0,0 +1,36 @@ +import { shallowMount } from '@vue/test-utils'; +import { InfoCell } from 'ee/licenses/components/cells'; + +describe('InfoCell', () => { + let wrapper; + const defaultProps = { + title: 'title', + value: 'value', + popoverContent: 'popoverContent', + }; + + function createComponent(props, slots) { + const propsData = Object.assign({}, defaultProps, props); + + wrapper = shallowMount(InfoCell, { + propsData, + slots, + }); + } + + afterEach(() => { + if (wrapper) wrapper.destroy(); + }); + + it('renders a title and string value with an info popover through props', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders a number value', () => { + createComponent({ value: 100 }); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/ee/spec/frontend/licenses/components/cells/skeleton_cell_spec.js b/ee/spec/frontend/licenses/components/cells/skeleton_cell_spec.js new file mode 100644 index 00000000000..fd52343c02d --- /dev/null +++ b/ee/spec/frontend/licenses/components/cells/skeleton_cell_spec.js @@ -0,0 +1,20 @@ +import { shallowMount } from '@vue/test-utils'; +import { SkeletonCell } from 'ee/licenses/components/cells'; + +describe('SkeletonCell', () => { + let wrapper; + + function createComponent() { + wrapper = shallowMount(SkeletonCell); + } + + afterEach(() => { + if (wrapper) wrapper.destroy(); + }); + + it('renders a skeleton cell with a title and value loading bar', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/ee/spec/frontend/licenses/components/cells/skeleton_header_cell_spec.js b/ee/spec/frontend/licenses/components/cells/skeleton_header_cell_spec.js new file mode 100644 index 00000000000..3a847f67044 --- /dev/null +++ b/ee/spec/frontend/licenses/components/cells/skeleton_header_cell_spec.js @@ -0,0 +1,20 @@ +import { shallowMount } from '@vue/test-utils'; +import { SkeletonHeaderCell } from 'ee/licenses/components/cells'; + +describe('SkeletonHeaderCell', () => { + let wrapper; + + function createComponent() { + wrapper = shallowMount(SkeletonHeaderCell); + } + + afterEach(() => { + if (wrapper) wrapper.destroy(); + }); + + it('renders a skeleton cell with a single title loading bar', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/ee/spec/frontend/licenses/components/license_cards_list_spec.js b/ee/spec/frontend/licenses/components/license_cards_list_spec.js new file mode 100644 index 00000000000..5009b963879 --- /dev/null +++ b/ee/spec/frontend/licenses/components/license_cards_list_spec.js @@ -0,0 +1,50 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import InstanceCardsList from 'ee/licenses/components/license_cards_list.vue'; +import * as getters from 'ee/licenses/store/getters'; +import createState from 'ee/licenses/store/state'; + +describe('InstanceCardsList', () => { + const newLicensePath = '/newLicensePath'; + let wrapper; + + const localVue = createLocalVue(); + localVue.use(Vuex); + + function createStore(store) { + const state = Object.assign(createState(), store, { + newLicensePath, + }); + + return new Vuex.Store({ state, getters }); + } + + function createComponent(store) { + wrapper = shallowMount(InstanceCardsList, { + store: createStore(store), + localVue, + }); + } + + afterEach(() => { + if (wrapper) wrapper.destroy(); + }); + + it('renders a list of license cards', () => { + createComponent({ licenses: [{ id: 1 }, { id: 2 }], isLoadingLicenses: false }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders a skeleton loading card if loading licenses', () => { + createComponent({ isLoadingLicenses: true }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders a message when there are no licenses', () => { + createComponent({ licenses: [], isLoadingLicenses: false }); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index c5432b25ad3..e49b085b40b 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1221,6 +1221,9 @@ msgstr "" msgid "Are you sure you want to lose your issue information?" msgstr "" +msgid "Are you sure you want to permanently delete this license?" +msgstr "" + msgid "Are you sure you want to regenerate the public key? You will have to update the public key on the remote server before mirroring will work again." msgstr "" @@ -2834,6 +2837,9 @@ msgstr "" msgid "Commit…" msgstr "" +msgid "Company" +msgstr "" + msgid "Compare" msgstr "" @@ -3374,6 +3380,9 @@ msgstr "" msgid "Delete comment" msgstr "" +msgid "Delete license" +msgstr "" + msgid "Delete list" msgstr "" @@ -3386,6 +3395,15 @@ msgstr "" msgid "Deleted" msgstr "" +msgid "Deleting the license failed." +msgstr "" + +msgid "Deleting the license failed. The license was not found." +msgstr "" + +msgid "Deleting the license failed. You are not permitted to perform this action." +msgstr "" + msgid "Deny" msgstr "" @@ -3679,6 +3697,9 @@ msgstr "" msgid "Download asset" msgstr "" +msgid "Download license" +msgstr "" + msgid "Download source code" msgstr "" @@ -3772,6 +3793,9 @@ msgstr "" msgid "Email" msgstr "" +msgid "Email address" +msgstr "" + msgid "Email patch" msgstr "" @@ -3868,6 +3892,9 @@ msgstr "" msgid "Enabling this will only make licensed EE features available to projects if the project namespace's plan includes the feature or if the project is public." msgstr "" +msgid "End date" +msgstr "" + msgid "Ends at (UTC)" msgstr "" @@ -4273,6 +4300,9 @@ msgstr "" msgid "Expiration date" msgstr "" +msgid "Expired" +msgstr "" + msgid "Expired %{expiredOn}" msgstr "" @@ -4519,6 +4549,15 @@ msgstr "" msgid "Fetching incoming email" msgstr "" +msgid "Fetching licenses failed." +msgstr "" + +msgid "Fetching licenses failed. The request endpoint was not found." +msgstr "" + +msgid "Fetching licenses failed. You are not permitted to perform this action." +msgstr "" + msgid "Fields on this page are now uneditable, you can configure" msgstr "" @@ -5160,6 +5199,9 @@ msgstr "" msgid "GitLab CI Linter has been moved" msgstr "" +msgid "GitLab Enterprise Edition %{plan}" +msgstr "" + msgid "GitLab Geo" msgstr "" @@ -5175,6 +5217,9 @@ msgstr "" msgid "GitLab User" msgstr "" +msgid "GitLab allows you to continue using your license even if you exceed the number of seats you purchased. You will be required to pay for these seats when you renew your license." +msgstr "" + msgid "GitLab metadata URL" msgstr "" @@ -5836,6 +5881,9 @@ msgstr "" msgid "Instance does not support multiple Kubernetes clusters" msgstr "" +msgid "Instance license" +msgstr "" + msgid "Integrations" msgstr "" @@ -6354,6 +6402,9 @@ msgstr "" msgid "LicenseManagement|You are about to remove the license, %{name}, from this project." msgstr "" +msgid "Licensed to" +msgstr "" + msgid "Licenses" msgstr "" @@ -6461,6 +6512,9 @@ msgstr "" msgid "Make sure you're logged into the account that owns the projects you'd like to import." msgstr "" +msgid "Manage" +msgstr "" + msgid "Manage Git repositories with fine-grained access controls that keep your code secure. Perform code reviews and enhance collaboration with merge requests. Each project can also have an issue tracker and a wiki." msgstr "" @@ -6539,6 +6593,9 @@ msgstr "" msgid "Max access level" msgstr "" +msgid "Max seats used" +msgstr "" + msgid "Maximum artifacts size (MB)" msgstr "" @@ -7159,6 +7216,9 @@ msgstr "" msgid "No license. All rights reserved" msgstr "" +msgid "No licenses found." +msgstr "" + msgid "No matching results" msgstr "" @@ -8702,6 +8762,9 @@ msgstr "" msgid "Register and see your runners for this project." msgstr "" +msgid "Registration" +msgstr "" + msgid "Registry" msgstr "" @@ -8777,6 +8840,9 @@ msgstr "" msgid "Removing group will cause all child projects and resources to be removed." msgstr "" +msgid "Removing license…" +msgstr "" + msgid "Rename" msgstr "" @@ -9266,6 +9332,12 @@ msgstr "" msgid "SearchResults|Showing %{from} - %{to} of %{count} %{scope} for \"%{term}\"" msgstr "" +msgid "Seats currently in use" +msgstr "" + +msgid "Seats in license" +msgstr "" + msgid "Secret" msgstr "" @@ -10779,6 +10851,9 @@ msgstr "" msgid "This is the author's first Merge Request to this project." msgstr "" +msgid "This is the maximum number of users that have existed at the same time since the license started. This is the minimum number of seats you will need to buy when you renew your license." +msgstr "" + msgid "This issue is confidential" msgstr "" @@ -11392,6 +11467,9 @@ msgstr "" msgid "Unknown" msgstr "" +msgid "Unlimited" +msgstr "" + msgid "Unlock" msgstr "" @@ -11521,12 +11599,18 @@ msgstr "" msgid "UploadLink|click to upload" msgstr "" +msgid "Uploaded on" +msgstr "" + msgid "Upstream" msgstr "" msgid "Upvotes" msgstr "" +msgid "Usage" +msgstr "" + msgid "Usage ping is not enabled" msgstr "" @@ -11689,12 +11773,18 @@ msgstr "" msgid "Users" msgstr "" +msgid "Users outside of license" +msgstr "" + msgid "Users requesting access to" msgstr "" msgid "Users were successfully added." msgstr "" +msgid "Users with a Guest role or those who don't belong to any projects or groups don't count towards seats in use." +msgstr "" + msgid "Validate" msgstr "" @@ -11704,6 +11794,9 @@ msgstr "" msgid "Validations failed." msgstr "" +msgid "Validity" +msgstr "" + msgid "Value" msgstr "" -- 2.30.9