diff options
Diffstat (limited to 'app/assets/javascripts/environments')
10 files changed, 402 insertions, 82 deletions
diff --git a/app/assets/javascripts/environments/environment_details/components/deployment_job.vue b/app/assets/javascripts/environments/environment_details/components/deployment_job.vue new file mode 100644 index 00000000000..dbe25a81550 --- /dev/null +++ b/app/assets/javascripts/environments/environment_details/components/deployment_job.vue @@ -0,0 +1,24 @@ +<script> +import { GlTruncate, GlLink, GlBadge } from '@gitlab/ui'; + +export default { + components: { + GlBadge, + GlTruncate, + GlLink, + }, + props: { + job: { + type: Object, + required: false, + default: null, + }, + }, +}; +</script> +<template> + <gl-link v-if="job" :href="job.webPath"> + <gl-truncate :text="job.label" /> + </gl-link> + <gl-badge v-else variant="info">{{ __('API') }}</gl-badge> +</template> diff --git a/app/assets/javascripts/environments/environment_details/components/deployment_status_link.vue b/app/assets/javascripts/environments/environment_details/components/deployment_status_link.vue new file mode 100644 index 00000000000..82926e2e596 --- /dev/null +++ b/app/assets/javascripts/environments/environment_details/components/deployment_status_link.vue @@ -0,0 +1,26 @@ +<script> +import DeploymentStatusBadge from '~/environments/components/deployment_status_badge.vue'; + +export default { + components: { + DeploymentStatusBadge, + }, + props: { + status: { + type: String, + required: true, + }, + deploymentJob: { + type: Object, + required: false, + default: null, + }, + }, +}; +</script> +<template> + <a v-if="deploymentJob" data-testid="deployment-status-job-link" :href="deploymentJob.webPath"> + <deployment-status-badge :status="status" /> + </a> + <deployment-status-badge v-else :status="status" /> +</template> diff --git a/app/assets/javascripts/environments/environment_details/components/deployment_triggerer.vue b/app/assets/javascripts/environments/environment_details/components/deployment_triggerer.vue new file mode 100644 index 00000000000..18ff31f9b0f --- /dev/null +++ b/app/assets/javascripts/environments/environment_details/components/deployment_triggerer.vue @@ -0,0 +1,25 @@ +<script> +import { GlAvatar, GlAvatarLink, GlTooltipDirective } from '@gitlab/ui'; + +export default { + components: { + GlAvatar, + GlAvatarLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + triggerer: { + type: Object, + required: false, + default: null, + }, + }, +}; +</script> +<template> + <gl-avatar-link v-if="triggerer" :href="triggerer.webUrl"> + <gl-avatar v-gl-tooltip :title="triggerer.name" :src="triggerer.avatarUrl" :size="24" /> + </gl-avatar-link> +</template> diff --git a/app/assets/javascripts/environments/environment_details/constants.js b/app/assets/javascripts/environments/environment_details/constants.js index 56c70c354b7..bf690ffedeb 100644 --- a/app/assets/javascripts/environments/environment_details/constants.js +++ b/app/assets/javascripts/environments/environment_details/constants.js @@ -1,4 +1,5 @@ -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; export const ENVIRONMENT_DETAILS_PAGE_SIZE = 20; export const ENVIRONMENT_DETAILS_TABLE_FIELDS = [ @@ -45,3 +46,17 @@ export const ENVIRONMENT_DETAILS_TABLE_FIELDS = [ tdClass: 'gl-vertical-align-middle! gl-white-space-nowrap', }, ]; + +export const translations = { + emptyStateTitle: s__("Deployments|You don't have any deployments right now."), + emptyStatePrimaryButton: __('Read more'), + emptyStateDescription: s__( + 'Deployments|Define environments in the deploy stage(s) in %{code_open}.gitlab-ci.yml%{code_close} to track deployments here.', + ), + nextPageButtonLabel: __('Next'), + previousPageButtonLabel: __('Prev'), +}; + +export const codeBlockPlaceholders = { code: ['code_open', 'code_close'] }; + +export const environmentsHelpPagePath = helpPagePath('ci/environments/index.md'); diff --git a/app/assets/javascripts/environments/environment_details/deployments_table.vue b/app/assets/javascripts/environments/environment_details/deployments_table.vue new file mode 100644 index 00000000000..41570ee44c0 --- /dev/null +++ b/app/assets/javascripts/environments/environment_details/deployments_table.vue @@ -0,0 +1,55 @@ +<script> +import { GlTableLite } from '@gitlab/ui'; +import Commit from '~/vue_shared/components/commit.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import DeploymentStatusLink from './components/deployment_status_link.vue'; +import DeploymentJob from './components/deployment_job.vue'; +import DeploymentTriggerer from './components/deployment_triggerer.vue'; +import { ENVIRONMENT_DETAILS_TABLE_FIELDS } from './constants'; + +export default { + components: { + DeploymentTriggerer, + DeploymentJob, + Commit, + TimeAgoTooltip, + DeploymentStatusLink, + GlTableLite, + }, + props: { + deployments: { + type: Array, + required: true, + }, + }, + tableFields: ENVIRONMENT_DETAILS_TABLE_FIELDS, +}; +</script> +<template> + <gl-table-lite :items="deployments" :fields="$options.tableFields" fixed stacked="lg"> + <template #table-colgroup="{ fields }"> + <col v-for="field in fields" :key="field.key" :class="field.columnClass" /> + </template> + <template #cell(status)="{ item }"> + <deployment-status-link :deployment-job="item.job" :status="item.status" /> + </template> + <template #cell(id)="{ item }"> + <strong>{{ item.id }}</strong> + </template> + <template #cell(triggerer)="{ item }"> + <deployment-triggerer :triggerer="item.triggerer" /> + </template> + <template #cell(commit)="{ item }"> + <commit v-bind="item.commit" /> + </template> + <template #cell(job)="{ item }"> + <deployment-job :job="item.job" /> + </template> + <template #cell(created)="{ item }"> + <time-ago-tooltip :time="item.created" /> + </template> + <template #cell(deployed)="{ item }"> + <time-ago-tooltip :time="item.deployed" /> + </template> + </gl-table-lite> +</template> diff --git a/app/assets/javascripts/environments/environment_details/empty_state.vue b/app/assets/javascripts/environments/environment_details/empty_state.vue new file mode 100644 index 00000000000..6f08b319408 --- /dev/null +++ b/app/assets/javascripts/environments/environment_details/empty_state.vue @@ -0,0 +1,34 @@ +<script> +import { GlEmptyState, GlSprintf } from '@gitlab/ui'; +import { translations, codeBlockPlaceholders, environmentsHelpPagePath } from './constants'; + +export default { + components: { + GlSprintf, + GlEmptyState, + }, + translations, + actionButtonUrl: environmentsHelpPagePath, + placeholders: { + code: codeBlockPlaceholders, + }, +}; +</script> +<template> + <gl-empty-state + :title="$options.translations.emptyStateTitle" + :primary-button-text="$options.translations.emptyStatePrimaryButton" + :primary-button-link="$options.actionButtonUrl" + > + <template #description> + <gl-sprintf + :message="$options.translations.emptyStateDescription" + :placeholders="$options.placeholders.code" + > + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/environments/environment_details/index.vue b/app/assets/javascripts/environments/environment_details/index.vue index 435d3fd820e..b43f4233b9c 100644 --- a/app/assets/javascripts/environments/environment_details/index.vue +++ b/app/assets/javascripts/environments/environment_details/index.vue @@ -1,36 +1,19 @@ <script> -import { - GlTableLite, - GlAvatarLink, - GlAvatar, - GlLink, - GlTooltipDirective, - GlTruncate, - GlBadge, - GlLoadingIcon, -} from '@gitlab/ui'; -import Commit from '~/vue_shared/components/commit.vue'; -import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { logError } from '~/lib/logger'; import environmentDetailsQuery from '../graphql/queries/environment_details.query.graphql'; import { convertToDeploymentTableRow } from '../helpers/deployment_data_transformation_helper'; -import DeploymentStatusBadge from '../components/deployment_status_badge.vue'; -import { ENVIRONMENT_DETAILS_PAGE_SIZE, ENVIRONMENT_DETAILS_TABLE_FIELDS } from './constants'; +import EmptyState from './empty_state.vue'; +import DeploymentsTable from './deployments_table.vue'; +import Pagination from './pagination.vue'; +import { ENVIRONMENT_DETAILS_PAGE_SIZE } from './constants'; export default { components: { + Pagination, + DeploymentsTable, + EmptyState, GlLoadingIcon, - GlBadge, - DeploymentStatusBadge, - TimeAgoTooltip, - GlTableLite, - GlAvatarLink, - GlAvatar, - GlLink, - GlTruncate, - Commit, - }, - directives: { - GlTooltip: GlTooltipDirective, }, props: { projectFullPath: { @@ -41,6 +24,16 @@ export default { type: String, required: true, }, + after: { + type: String, + required: false, + default: null, + }, + before: { + type: String, + required: false, + default: null, + }, }, apollo: { project: { @@ -49,18 +42,19 @@ export default { return { projectFullPath: this.projectFullPath, environmentName: this.environmentName, - pageSize: ENVIRONMENT_DETAILS_PAGE_SIZE, + first: this.before ? null : ENVIRONMENT_DETAILS_PAGE_SIZE, + last: this.before ? ENVIRONMENT_DETAILS_PAGE_SIZE : null, + after: this.after, + before: this.before, }; }, }, }, data() { return { - project: { - loading: true, - }, - loading: 0, - tableFields: ENVIRONMENT_DETAILS_TABLE_FIELDS, + project: {}, + isInitialPageDataReceived: false, + isPrefetchingPages: false, }; }, computed: { @@ -70,49 +64,80 @@ export default { isLoading() { return this.$apollo.queries.project.loading; }, + isDeploymentTableShown() { + return this.isInitialPageDataReceived === true && this.deployments.length > 0; + }, + pageInfo() { + return this.project.environment?.deployments.pageInfo || {}; + }, + isPaginationDisabled() { + return this.isLoading || this.isPrefetchingPages; + }, + }, + watch: { + async project(newProject) { + this.isInitialPageDataReceived = true; + this.isPrefetchingPages = true; + + try { + // TLDR: when we load a page, if there's next and/or previous pages existing, we'll load their data as well to improve percepted performance. + const { + endCursor, + hasPreviousPage, + hasNextPage, + startCursor, + } = newProject.environment.deployments.pageInfo; + + // At the moment we have a limit of deployments being requested only from a signle environment entity per query, + // and apparently two batched queries count as one on server-side + // to load both next and previous page data, we have to query them sequentially + if (hasNextPage) { + await this.$apollo.query({ + query: environmentDetailsQuery, + variables: { + projectFullPath: this.projectFullPath, + environmentName: this.environmentName, + first: ENVIRONMENT_DETAILS_PAGE_SIZE, + after: endCursor, + before: null, + last: null, + }, + }); + } + + if (hasPreviousPage) { + await this.$apollo.query({ + query: environmentDetailsQuery, + variables: { + projectFullPath: this.projectFullPath, + environmentName: this.environmentName, + first: null, + after: null, + before: startCursor, + last: ENVIRONMENT_DETAILS_PAGE_SIZE, + }, + }); + } + } catch (error) { + logError(error); + } + + this.isPrefetchingPages = false; + }, }, }; </script> <template> - <div> - <gl-loading-icon v-if="isLoading" size="lg" class="mt-3" /> - <gl-table-lite v-else :items="deployments" :fields="tableFields" fixed stacked="lg"> - <template #table-colgroup="{ fields }"> - <col v-for="field in fields" :key="field.key" :class="field.columnClass" /> - </template> - <template #cell(status)="{ item }"> - <div> - <deployment-status-badge :status="item.status" /> - </div> - </template> - <template #cell(id)="{ item }"> - <strong>{{ item.id }}</strong> - </template> - <template #cell(triggerer)="{ item }"> - <gl-avatar-link :href="item.triggerer.webUrl"> - <gl-avatar - v-gl-tooltip - :title="item.triggerer.name" - :src="item.triggerer.avatarUrl" - :size="24" - /> - </gl-avatar-link> - </template> - <template #cell(commit)="{ item }"> - <commit v-bind="item.commit" /> - </template> - <template #cell(job)="{ item }"> - <gl-link v-if="item.job" :href="item.job.webPath"> - <gl-truncate :text="item.job.label" /> - </gl-link> - <gl-badge v-else variant="info">{{ __('API') }}</gl-badge> - </template> - <template #cell(created)="{ item }"> - <time-ago-tooltip :time="item.created" /> - </template> - <template #cell(deployed)="{ item }"> - <time-ago-tooltip :time="item.deployed" /> - </template> - </gl-table-lite> + <div class="gl-relative gl-min-h-6"> + <div + v-if="isLoading" + class="gl-absolute gl-top-0 gl-left-0 gl-w-full gl-h-full gl-z-index-200 gl-bg-gray-10 gl-opacity-3" + ></div> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-absolute gl-top-half gl-left-50p" /> + <div v-if="isDeploymentTableShown"> + <deployments-table :deployments="deployments" /> + <pagination :page-info="pageInfo" :disabled="isPaginationDisabled" /> + </div> + <empty-state v-if="!isDeploymentTableShown && !isLoading" /> </div> </template> diff --git a/app/assets/javascripts/environments/environment_details/pagination.vue b/app/assets/javascripts/environments/environment_details/pagination.vue new file mode 100644 index 00000000000..414610b306a --- /dev/null +++ b/app/assets/javascripts/environments/environment_details/pagination.vue @@ -0,0 +1,74 @@ +<script> +import { GlKeysetPagination } from '@gitlab/ui'; +import { setUrlParams } from '~/lib/utils/url_utility'; +import { translations } from './constants'; + +export default { + components: { + GlKeysetPagination, + }, + props: { + pageInfo: { + type: Object, + required: true, + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + }, + translations, + computed: { + previousLink() { + if (!this.pageInfo || !this.pageInfo.hasPreviousPage) { + return ''; + } + return setUrlParams({ before: this.pageInfo.startCursor }, window.location.href, true); + }, + nextLink() { + if (!this.pageInfo || !this.pageInfo.hasNextPage) { + return ''; + } + return setUrlParams({ after: this.pageInfo.endCursor }, window.location.href, true); + }, + isPaginationVisible() { + if (!this.pageInfo) { + return false; + } + + return this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage; + }, + }, + methods: { + onPrev(previousCursor) { + this.$router.push({ query: { before: previousCursor } }); + }, + onNext(nextCursor) { + this.$router.push({ query: { after: nextCursor } }); + }, + onPaginationClick(event) { + // this check here is to ensure the proper default behvaior when a user ctrl/cmd + clicks the link + if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) { + return; + } + event.preventDefault(); + }, + }, +}; +</script> +<template> + <div v-if="isPaginationVisible" class="gl--flex-center"> + <gl-keyset-pagination + v-bind="pageInfo" + :prev-text="$options.translations.previousPageButtonLabel" + :next-text="$options.translations.nextPageButtonLabel" + :prev-button-link="previousLink" + :next-button-link="nextLink" + :disabled="disabled" + @prev="onPrev" + @next="onNext" + @click="onPaginationClick" + /> + </div> +</template> diff --git a/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql index e8f2a2cdf7f..c6c2024c840 100644 --- a/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql @@ -1,4 +1,11 @@ -query getEnvironmentDetails($projectFullPath: ID!, $environmentName: String, $pageSize: Int) { +query getEnvironmentDetails( + $projectFullPath: ID! + $environmentName: String + $first: Int + $last: Int + $after: String + $before: String +) { project(fullPath: $projectFullPath) { id name @@ -6,7 +13,19 @@ query getEnvironmentDetails($projectFullPath: ID!, $environmentName: String, $pa environment(name: $environmentName) { id name - deployments(orderBy: { createdAt: DESC }, first: $pageSize) { + deployments( + orderBy: { createdAt: DESC } + first: $first + last: $last + after: $after + before: $before + ) { + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } nodes { id iid diff --git a/app/assets/javascripts/environments/mount_show.js b/app/assets/javascripts/environments/mount_show.js index ba816599ac2..afce2b7f237 100644 --- a/app/assets/javascripts/environments/mount_show.js +++ b/app/assets/javascripts/environments/mount_show.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import EnvironmentsDetailHeader from './components/environments_detail_header.vue'; import { apolloProvider } from './graphql/client'; @@ -43,7 +44,7 @@ export const initHeader = () => { cancelAutoStopPath: dataset.environmentCancelAutoStopPath, terminalPath: dataset.environmentTerminalPath, metricsPath: dataset.environmentMetricsPath, - updatePath: dataset.tnvironmentEditPath, + updatePath: dataset.environmentEditPath, }, }); }, @@ -60,18 +61,40 @@ export const initPage = async () => { const dataSet = convertObjectPropsToCamelCase(JSON.parse(dataElement.dataset.details)); Vue.use(VueApollo); + Vue.use(VueRouter); const el = document.getElementById('environment_details_page'); + + const router = new VueRouter({ + mode: 'history', + base: window.location.pathname, + routes: [ + { + path: '/', + name: 'environment_details', + component: EnvironmentsDetailPage, + props: (route) => ({ + after: route.query.after, + before: route.query.before, + projectFullPath: dataSet.projectFullPath, + environmentName: dataSet.name, + }), + }, + ], + scrollBehavior(to, from, savedPosition) { + if (savedPosition) { + return savedPosition; + } + return { top: 0 }; + }, + }); + return new Vue({ el, apolloProvider: apolloProvider(), + router, provide: {}, render(createElement) { - return createElement(EnvironmentsDetailPage, { - props: { - projectFullPath: dataSet.projectFullPath, - environmentName: dataSet.name, - }, - }); + return createElement('router-view'); }, }); }; |