Skip to content
Snippets Groups Projects
Verified Commit 8bedb3a5 authored by Janis Altherr's avatar Janis Altherr :red_circle: Committed by GitLab
Browse files

Add pages view to project usage quota


This commit adds a Pages tab to the project
level usage quota view

Signed-off-by: Janis Altherr's avatarjanis <jaltherr@gitlab.com>
parent 7d6727ba
No related branches found
No related tags found
2 merge requests!181325Fix ambiguous `created_at` in project.rb,!173818Move Multiple Deployments limit from top-level namespace to project if `unique_domain` is enabled.
......@@ -2,6 +2,7 @@
import { GlCard, GlTableLite, GlIcon, GlBadge, GlSprintf, GlLink, GlAvatar } from '@gitlab/ui';
import NumberToHumanSize from '~/vue_shared/components/number_to_human_size/number_to_human_size.vue';
import { SHORT_DATE_FORMAT_WITH_TIME } from '~/vue_shared/constants';
import { PROJECT_VIEW_TYPE } from '~/usage_quotas/constants';
import UserDate from '~/vue_shared/components/user_date.vue';
import { joinPaths } from '~/lib/utils/url_utility';
import { s__, sprintf } from '~/locale';
......@@ -21,6 +22,7 @@ export default {
GlLink,
GlAvatar,
},
inject: ['viewType'],
props: {
project: {
type: Object,
......@@ -28,6 +30,7 @@ export default {
},
},
static: {
PROJECT_VIEW_TYPE,
SHORT_DATE_FORMAT_WITH_TIME,
},
i18n: {
......@@ -85,6 +88,9 @@ export default {
},
],
computed: {
isSingleProjectView() {
return this.viewType === this.$options.static.PROJECT_VIEW_TYPE;
},
pagesUrl() {
return joinPaths(gon.relative_url_root || '', '/', this.project.fullPath, 'pages');
},
......@@ -115,7 +121,7 @@ export default {
<template>
<gl-card body-class="gl-p-0">
<template #header>
<template v-if="!isSingleProjectView" #header>
<div class="gl-flex gl-items-center gl-justify-between" data-testid="project-name">
<gl-link :href="pagesUrl" class="gl-flex gl-items-center gl-no-underline">
<gl-avatar
......
<script>
import { GlEmptyState, GlLoadingIcon, GlAlert } from '@gitlab/ui';
import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/empty-state/empty-search-md.svg?url';
import { PROJECT_VIEW_TYPE } from '~/usage_quotas/constants';
import GetProjectPagesDeployments from '~/gitlab_pages/queries/get_project_pages_deployments.graphql';
import GetNamespacePagesDeployments from '../graphql/pages_deployments.query.graphql';
import ProjectView from './project.vue';
......@@ -13,7 +15,8 @@ export default {
GlAlert,
},
EMPTY_STATE_SVG_URL,
inject: ['fullPath'],
PROJECT_VIEW_TYPE,
inject: ['fullPath', 'viewType'],
props: {
sort: {
type: String,
......@@ -23,14 +26,36 @@ export default {
},
data() {
return {
project: null,
projects: {},
resultsPerPage: 15,
error: null,
};
},
apollo: {
project: {
query: GetProjectPagesDeployments,
skip() {
return !this.isProjectView;
},
variables() {
return {
fullPath: this.fullPath,
first: this.resultsPerPage,
sort: this.sort,
active: true,
versioned: true,
};
},
error(error) {
this.error = error;
},
},
projects: {
query: GetNamespacePagesDeployments,
skip() {
return this.isProjectView;
},
variables() {
return {
fullPath: this.fullPath,
......@@ -51,7 +76,13 @@ export default {
},
},
computed: {
isProjectView() {
return this.viewType === this.$options.PROJECT_VIEW_TYPE;
},
hasResults() {
if (this.isProjectView) {
return this.project.pagesDeployments.nodes?.length;
}
return this.projects?.length;
},
},
......@@ -65,15 +96,22 @@ export default {
{{ s__('Pages|An error occurred trying to load the Pages deployments.') }}
</gl-alert>
<gl-empty-state
v-else-if="!hasResults"
v-else-if="!isProjectView && !hasResults"
:title="__('No projects found')"
:description="
s__('Pages|We did not find any projects with parallel Pages deployments in this namespace.')
"
:svg-path="$options.EMPTY_STATE_SVG_URL"
/>
<gl-empty-state
v-else-if="isProjectView && !hasResults"
:title="__('No parallel deployments')"
:description="s__('Pages|There are no active parallel Pages deployments in this project.')"
:svg-path="$options.EMPTY_STATE_SVG_URL"
/>
<div v-else class="gl-flex gl-flex-col gl-gap-4">
<project-view v-for="node in projects" :key="node.id" :project="node" />
<project-view v-if="isProjectView" :project="project" />
<project-view v-for="node in projects" v-else :key="node.id" :project="node" />
</div>
</div>
</template>
......@@ -13,7 +13,7 @@ export const parseProvideData = (el) => {
};
};
export const getPagesTabMetadata = () => {
export const getPagesTabMetadata = ({ viewType } = {}) => {
const el = document.querySelector(PAGES_TAB_METADATA_EL_SELECTOR);
if (!el) return false;
......@@ -25,7 +25,10 @@ export const getPagesTabMetadata = () => {
component: {
name: 'PagesDeploymentsTab',
apolloProvider,
provide: parseProvideData(el),
provide: {
viewType,
...parseProvideData(el),
},
render(createElement) {
return createElement(PagesDeploymentsApp);
},
......
......@@ -2,9 +2,11 @@ import { PROJECT_VIEW_TYPE } from '~/usage_quotas/constants';
import { getStorageTabMetadata } from '~/usage_quotas/storage/tab_metadata';
import { getTransferTabMetadata } from './transfer/tab_metadata';
import { getObservabilityTabMetadata } from './observability/tab_metadata';
import { getPagesTabMetadata } from './pages/tab_metadata';
export const usageQuotasTabsMetadata = [
getStorageTabMetadata({ viewType: PROJECT_VIEW_TYPE }),
getTransferTabMetadata({ viewType: PROJECT_VIEW_TYPE }),
getObservabilityTabMetadata(),
getPagesTabMetadata({ viewType: PROJECT_VIEW_TYPE }),
].filter(Boolean);
......@@ -130,6 +130,75 @@ export const getNamespacePagesDeploymentsMockData = {
},
};
export const getProjectPagesDeploymentsMockData = {
data: {
project: {
id: 'gid://gitlab/Project/19',
pagesDeployments: {
count: 3,
pageInfo: {
startCursor:
'eyJjcmVhdGVkX2F0IjoiMjAyNC0wNS0yMiAxMzozNzoyMi40MTk4MzcwMDAgKzAwMDAiLCJpZCI6IjQ4In0',
endCursor:
'eyJjcmVhdGVkX2F0IjoiMjAyNC0wNS0yMiAxMzozNzoyMi40MTk4MzcwMDAgKzAwMDAiLCJpZCI6IjQ2In0',
hasNextPage: false,
hasPreviousPage: false,
__typename: 'PageInfo',
},
nodes: [
{
id: 'gid://gitlab/PagesDeployment/48',
active: true,
ciBuildId: '499',
createdAt: '2024-05-22T13:37:22Z',
deletedAt: null,
expiresAt: null,
fileCount: 3,
pathPrefix: '_mr2019',
rootDirectory: 'public',
size: 1082,
updatedAt: '2024-08-07T13:14:10Z',
url: 'http://my-html-page-root-57991cf20198ae591a39bb7e54a451c8050c28335f427.pages.gdk.test:3010/_mr2019',
__typename: 'PagesDeployment',
},
{
id: 'gid://gitlab/PagesDeployment/47',
active: true,
ciBuildId: '499',
createdAt: '2024-05-22T13:37:22Z',
deletedAt: null,
expiresAt: null,
fileCount: 3,
pathPrefix: '_mr2018',
rootDirectory: 'public',
size: 1082,
updatedAt: '2024-08-07T13:14:21Z',
url: 'http://my-html-page-root-57991cf20198ae591a39bb7e54a451c8050c28335f427.pages.gdk.test:3010/_mr2018',
__typename: 'PagesDeployment',
},
{
id: 'gid://gitlab/PagesDeployment/46',
active: true,
ciBuildId: '499',
createdAt: '2024-05-22T13:37:22Z',
deletedAt: null,
expiresAt: null,
fileCount: 3,
pathPrefix: '_mr2017',
rootDirectory: 'public',
size: 1082,
updatedAt: '2024-08-07T13:14:26Z',
url: 'http://my-html-page-root-57991cf20198ae591a39bb7e54a451c8050c28335f427.pages.gdk.test:3010/_mr2017',
__typename: 'PagesDeployment',
},
],
__typename: 'PagesDeploymentConnection',
},
__typename: 'Project',
},
},
};
export const getEmptyNamespacePagesDeploymentsMockData = {
data: {
namespace: {
......@@ -144,6 +213,27 @@ export const getEmptyNamespacePagesDeploymentsMockData = {
},
};
export const getEmptyProjectPagesDeploymentsMockData = {
data: {
project: {
id: 'gid://gitlab/Project/19',
pagesDeployments: {
count: 0,
pageInfo: {
startCursor: null,
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
__typename: 'PageInfo',
},
nodes: [],
__typename: 'PagesDeploymentConnection',
},
__typename: 'Project',
},
},
};
export const mockError = {
errors: [
{
......
......@@ -4,12 +4,16 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import PagesProjects from 'ee/usage_quotas/pages/components/project_list.vue';
import ProjectView from 'ee/usage_quotas/pages/components/project.vue';
import { GROUP_VIEW_TYPE, PROFILE_VIEW_TYPE, PROJECT_VIEW_TYPE } from '~/usage_quotas/constants';
import createMockApollo from 'helpers/mock_apollo_helper';
import GetNamespacePagesDeployments from 'ee/usage_quotas/pages/graphql/pages_deployments.query.graphql';
import GetProjectPagesDeployments from '~/gitlab_pages/queries/get_project_pages_deployments.graphql';
import waitForPromises from 'helpers/wait_for_promises';
import {
getNamespacePagesDeploymentsMockData,
getProjectPagesDeploymentsMockData,
getEmptyNamespacePagesDeploymentsMockData,
getEmptyProjectPagesDeploymentsMockData,
mockError,
} from './mock_data';
......@@ -21,88 +25,155 @@ jest.mock(
Vue.use(VueApollo);
describe('PagesProjects', () => {
const mockProjects = getNamespacePagesDeploymentsMockData.data.namespace.projects.nodes;
const mockNamespaceProjects = getNamespacePagesDeploymentsMockData.data.namespace.projects.nodes;
const mockProject = getProjectPagesDeploymentsMockData.data.project;
const getNamespacePagesDeploymentsQueryHandler = jest
.fn()
.mockResolvedValue(getNamespacePagesDeploymentsMockData);
const getProjectPagesDeploymentsQueryHandler = jest
.fn()
.mockResolvedValue(getProjectPagesDeploymentsMockData);
const getAllHandlersMockedWithFn = (fn) => [
[GetNamespacePagesDeployments, fn],
[GetProjectPagesDeployments, fn],
];
const defaultHandler = [
[GetNamespacePagesDeployments, getNamespacePagesDeploymentsQueryHandler],
[GetProjectPagesDeployments, getProjectPagesDeploymentsQueryHandler],
];
const errorHandler = getAllHandlersMockedWithFn(jest.fn().mockRejectedValue(mockError));
const foreverLoadingHandler = getAllHandlersMockedWithFn(Promise);
const emptyResultsHandler = [
[
GetNamespacePagesDeployments,
jest.fn().mockResolvedValue(getEmptyNamespacePagesDeploymentsMockData),
],
[
GetProjectPagesDeployments,
jest.fn().mockResolvedValue(getEmptyProjectPagesDeploymentsMockData),
],
];
let wrapper;
let mockApollo;
let viewType;
const createComponent = (
queryHandler = jest.fn().mockResolvedValue(getNamespacePagesDeploymentsMockData),
props = {},
) => {
mockApollo = createMockApollo([[GetNamespacePagesDeployments, queryHandler]]);
const createComponent = (handler = defaultHandler, props = {}) => {
mockApollo = createMockApollo(handler);
return shallowMount(PagesProjects, {
propsData: props,
provide: {
fullPath: 'test/path',
viewType,
},
apolloProvider: mockApollo,
});
};
it('calls the apollo query with the expected variables', () => {
const handler = jest.fn();
describe.each`
view | expectedHandler
${GROUP_VIEW_TYPE} | ${getNamespacePagesDeploymentsQueryHandler}
${PROFILE_VIEW_TYPE} | ${getNamespacePagesDeploymentsQueryHandler}
${PROJECT_VIEW_TYPE} | ${getProjectPagesDeploymentsQueryHandler}
`(`in a $view`, ({ view, expectedHandler }) => {
beforeEach(() => {
viewType = view;
});
wrapper = createComponent(handler, { sort: 'UPDATED_ASC' });
it('calls the apollo query with the expected variables', () => {
wrapper = createComponent(defaultHandler, { sort: 'UPDATED_ASC' });
expect(handler).toHaveBeenCalledWith({
fullPath: 'test/path',
first: 15,
sort: 'UPDATED_ASC',
active: true,
versioned: true,
expect(expectedHandler).toHaveBeenCalledWith({
fullPath: 'test/path',
first: 15,
sort: 'UPDATED_ASC',
active: true,
versioned: true,
});
});
});
it('renders loading icon while loading', () => {
wrapper = createComponent(Promise);
it('renders loading icon while loading', () => {
wrapper = createComponent(foreverLoadingHandler);
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
});
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
});
it('renders project rows when there are results', async () => {
wrapper = createComponent();
it('does not show projects with no pages deployments', async () => {
wrapper = createComponent();
await waitForPromises();
await waitForPromises();
const projectRows = wrapper.findAllComponents(ProjectView);
expect(projectRows).toHaveLength(2);
expect(projectRows.at(0).props('project')).toEqual(mockProjects[0]);
expect(projectRows.at(1).props('project')).toEqual(mockProjects[2]);
});
const projectRows = wrapper.findAllComponents(ProjectView);
expect(projectRows.wrappers.map((w) => w.props('project').id)).not.toContain(
'gid://gitlab/Project/3',
);
});
it('does not show projects with no pages deployments', async () => {
wrapper = createComponent();
it('renders error alert when apollo has an error', async () => {
wrapper = createComponent(errorHandler);
await waitForPromises();
await waitForPromises();
const projectRows = wrapper.findAllComponents(ProjectView);
expect(projectRows.wrappers.map((w) => w.props('project').id)).not.toContain(
'gid://gitlab/Project/3',
);
const alert = wrapper.findComponent(GlAlert);
expect(alert.exists()).toBe(true);
expect(alert.props('variant')).toBe('danger');
expect(alert.text()).toContain('An error occurred trying to load the Pages deployments.');
});
});
it('renders error alert when apollo has an error', async () => {
wrapper = createComponent(jest.fn().mockRejectedValue(mockError));
describe.each([GROUP_VIEW_TYPE, PROFILE_VIEW_TYPE])('namespace view', (i, view) => {
beforeEach(() => {
viewType = view;
});
it('renders project rows when there are results', async () => {
wrapper = createComponent();
await waitForPromises();
await waitForPromises();
const alert = wrapper.findComponent(GlAlert);
expect(alert.exists()).toBe(true);
expect(alert.props('variant')).toBe('danger');
expect(alert.text()).toContain('An error occurred trying to load the Pages deployments.');
const projectRows = wrapper.findAllComponents(ProjectView);
expect(projectRows).toHaveLength(2);
expect(projectRows.at(0).props('project')).toEqual(mockNamespaceProjects[0]);
expect(projectRows.at(1).props('project')).toEqual(mockNamespaceProjects[2]);
});
it('renders empty state when the project list is empty', async () => {
wrapper = createComponent(emptyResultsHandler);
await waitForPromises();
const emptyState = wrapper.findComponent(GlEmptyState);
expect(emptyState.exists()).toBe(true);
expect(emptyState.props('title')).toBe('No projects found');
expect(emptyState.props('svgPath')).toBe('mocked-svg-url');
});
});
it('renders empty state when the project list is empty', async () => {
wrapper = createComponent(
jest.fn().mockResolvedValue(getEmptyNamespacePagesDeploymentsMockData),
);
describe('project view', () => {
beforeEach(() => {
viewType = PROJECT_VIEW_TYPE;
});
await waitForPromises();
it('renders project rows when there are results', async () => {
wrapper = createComponent();
const emptyState = wrapper.findComponent(GlEmptyState);
expect(emptyState.exists()).toBe(true);
expect(emptyState.props('title')).toBe('No projects found');
expect(emptyState.props('svgPath')).toBe('mocked-svg-url');
await waitForPromises();
const projectRows = wrapper.findAllComponents(ProjectView);
expect(projectRows).toHaveLength(1);
expect(projectRows.at(0).props('project')).toEqual(mockProject);
});
it('renders empty state when the project list is empty', async () => {
wrapper = createComponent(emptyResultsHandler);
await waitForPromises();
const emptyState = wrapper.findComponent(GlEmptyState);
expect(emptyState.exists()).toBe(true);
expect(emptyState.props('title')).toBe('No parallel deployments');
expect(emptyState.props('svgPath')).toBe('mocked-svg-url');
});
});
});
......@@ -3,6 +3,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper';
import ProjectView from 'ee/usage_quotas/pages/components/project.vue';
import UserDate from '~/vue_shared/components/user_date.vue';
import NumberToHumanSize from '~/vue_shared/components/number_to_human_size/number_to_human_size.vue';
import { GROUP_VIEW_TYPE, PROFILE_VIEW_TYPE, PROJECT_VIEW_TYPE } from '~/usage_quotas/constants';
describe('ProjectView', () => {
let wrapper;
......@@ -41,84 +42,118 @@ describe('ProjectView', () => {
const findAllUrls = () => wrapper.findAllByTestId('url');
const findAllCiBuilds = () => wrapper.findAllByTestId('ci-build');
const findAvatar = () => wrapper.findComponent(GlAvatar);
beforeEach(() => {
const createComponent = (viewType) => {
wrapper = mountExtended(ProjectView, {
propsData: {
project: mockProject,
},
provide: {
viewType,
},
});
});
};
it('renders the project name and avatar', () => {
const projectName = findProjectName();
const avatar = findAvatar();
expect(projectName.text()).toContain('Test Project');
expect(avatar.props('src')).toBe('http://example.com/avatar.png');
expect(projectName.find('a').attributes('href')).toBe('/group/test-project/pages');
});
describe.each([GROUP_VIEW_TYPE, PROFILE_VIEW_TYPE, PROJECT_VIEW_TYPE])(
'with viewType=%s',
(_, viewType) => {
beforeEach(() => {
createComponent(viewType);
});
it('displays the correct number of total deployments', () => {
expect(wrapper.text().replace(/\s\s+/g, ' ')).toContain('Parallel deployments: 100');
});
it('renders the correct number of deployment rows', () => {
expect(wrapper.findAll('.deployments-table tbody tr')).toHaveLength(2);
});
it('renders the correct number of deployment rows', () => {
expect(wrapper.findAll('.deployments-table tbody tr')).toHaveLength(2);
});
it('shows the correct state for active and inactive deployments', () => {
const badges = findAllStatusBadges();
expect(badges.at(0).text()).toContain('Active');
expect(badges.at(1).text()).toContain('Stopped');
});
it('shows the correct state for active and inactive deployments', () => {
const badges = findAllStatusBadges();
expect(badges.at(0).text()).toContain('Active');
expect(badges.at(1).text()).toContain('Stopped');
});
it('displays the correct path prefix for each deployment', () => {
const pathPrefixes = findAllPathPrefixes();
expect(pathPrefixes.at(0).text()).toContain('/foo');
expect(pathPrefixes.at(1).text()).toContain('/bar');
});
it('displays the correct path prefix for each deployment', () => {
const pathPrefixes = findAllPathPrefixes();
expect(pathPrefixes.at(0).text()).toContain('/foo');
expect(pathPrefixes.at(1).text()).toContain('/bar');
});
it('renders active URLs as links and inactive URLs as text', () => {
const urls = findAllUrls();
expect(urls.at(0).element.tagName).toBe('A');
expect(urls.at(0).attributes('href')).toBe('http://example.com/foo');
expect(urls.at(1).element.tagName).not.toBe('A');
});
it('renders active URLs as links and inactive URLs as text', () => {
const urls = findAllUrls();
expect(urls.at(0).element.tagName).toBe('A');
expect(urls.at(0).attributes('href')).toBe('http://example.com/foo');
expect(urls.at(1).element.tagName).not.toBe('A');
});
it('passes the creation date correctly to UserDate', () => {
const dates = wrapper.findAllComponents(UserDate);
expect(dates.at(0).props('date')).toBe('2023-01-01T00:00:00Z');
expect(dates.at(1).props('date')).toBe('2023-01-02T00:00:00Z');
});
it('passes the creation date correctly to UserDate', () => {
const dates = wrapper.findAllComponents(UserDate);
expect(dates.at(0).props('date')).toBe('2023-01-01T00:00:00Z');
expect(dates.at(1).props('date')).toBe('2023-01-02T00:00:00Z');
});
it('generates correct build URLs', () => {
const buildLinks = findAllCiBuilds();
expect(buildLinks.at(0).attributes('href')).toContain('/group/test-project/-/jobs/100');
expect(buildLinks.at(1).attributes('href')).toContain('/group/test-project/-/jobs/101');
});
it('generates correct build URLs', () => {
const buildLinks = findAllCiBuilds();
expect(buildLinks.at(0).attributes('href')).toContain('/group/test-project/-/jobs/100');
expect(buildLinks.at(1).attributes('href')).toContain('/group/test-project/-/jobs/101');
});
it('renders the size of deployments using NumberToHumanSize component', () => {
const sizes = wrapper.findAllComponents(NumberToHumanSize);
expect(sizes.at(0).props('value')).toBe(1024);
expect(sizes.at(1).props('value')).toBe(2048);
});
it('renders the size of deployments using NumberToHumanSize component', () => {
const sizes = wrapper.findAllComponents(NumberToHumanSize);
expect(sizes.at(0).props('value')).toBe(1024);
expect(sizes.at(1).props('value')).toBe(2048);
});
it('shows "View all" link when there are more deployments', () => {
expect(wrapper.text()).toContain('+ 98 more deployments');
expect(wrapper.text()).toContain('View all');
});
it('shows "View all" link when there are more deployments', () => {
expect(wrapper.text()).toContain('+ 98 more deployments');
expect(wrapper.text()).toContain('View all');
it('does not show "View all" link when all deployments are displayed', async () => {
await wrapper.setProps({
project: {
...mockProject,
pagesDeployments: {
count: 2,
nodes: mockProject.pagesDeployments.nodes,
},
},
});
expect(wrapper.text()).not.toContain('more deployments');
expect(wrapper.text()).not.toContain('View all');
});
},
);
describe.each([GROUP_VIEW_TYPE, PROFILE_VIEW_TYPE])('namespace view', (_, viewType) => {
beforeEach(() => {
createComponent(viewType);
});
it('renders the project name and avatar', () => {
const projectName = findProjectName();
const avatar = findAvatar();
expect(projectName.text()).toContain('Test Project');
expect(avatar.props('src')).toBe('http://example.com/avatar.png');
expect(projectName.find('a').attributes('href')).toBe('/group/test-project/pages');
});
it('displays the correct number of total deployments', () => {
expect(wrapper.text().replace(/\s\s+/g, ' ')).toContain('Parallel deployments: 100');
});
});
it('does not show "View all" link when all deployments are displayed', async () => {
await wrapper.setProps({
project: {
...mockProject,
pagesDeployments: {
count: 2,
nodes: mockProject.pagesDeployments.nodes,
},
},
describe('project view', () => {
beforeEach(() => {
createComponent(PROJECT_VIEW_TYPE);
});
it('does not render the project name and avatar', () => {
const projectName = findProjectName();
const avatar = findAvatar();
expect(avatar.exists()).toBe(false);
expect(projectName.exists()).toBe(false);
});
it('does not display the number of total deployments', () => {
expect(wrapper.text().replace(/\s\s+/g, ' ')).not.toContain('Parallel deployments');
});
expect(wrapper.text()).not.toContain('more deployments');
expect(wrapper.text()).not.toContain('View all');
});
});
......@@ -36858,6 +36858,9 @@ msgstr ""
msgid "No other labels with such name or description"
msgstr ""
 
msgid "No parallel deployments"
msgstr ""
msgid "No parent group"
msgstr ""
 
......@@ -40003,6 +40006,9 @@ msgstr ""
msgid "Pages|Stopped"
msgstr ""
 
msgid "Pages|There are no active parallel Pages deployments in this project."
msgstr ""
msgid "Pages|There was an error trying to delete the deployment"
msgstr ""
 
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment