Commit 253aa049 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Merge branch '271251-fe-devops-report-add-edit-and-delete-segment-functionality-2' into 'master'

DevOps Report: Edit segment functionality

See merge request gitlab-org/gitlab!49338
parents a40ef4fd 7773811b
...@@ -37,7 +37,7 @@ export default { ...@@ -37,7 +37,7 @@ export default {
return { return {
isLoadingGroups: false, isLoadingGroups: false,
requestCount: 0, requestCount: 0,
selectedSegmentId: null, selectedSegment: null,
errors: { errors: {
[DEVOPS_ADOPTION_ERROR_KEYS.groups]: false, [DEVOPS_ADOPTION_ERROR_KEYS.groups]: false,
[DEVOPS_ADOPTION_ERROR_KEYS.segments]: false, [DEVOPS_ADOPTION_ERROR_KEYS.segments]: false,
...@@ -75,6 +75,9 @@ export default { ...@@ -75,6 +75,9 @@ export default {
isLoading() { isLoading() {
return this.isLoadingGroups || this.$apollo.queries.devopsAdoptionSegments.loading; return this.isLoadingGroups || this.$apollo.queries.devopsAdoptionSegments.loading;
}, },
modalKey() {
return this.selectedSegment?.id;
},
}, },
created() { created() {
this.fetchGroups(); this.fetchGroups();
...@@ -111,6 +114,12 @@ export default { ...@@ -111,6 +114,12 @@ export default {
}) })
.catch(error => this.handleError(DEVOPS_ADOPTION_ERROR_KEYS.groups, error)); .catch(error => this.handleError(DEVOPS_ADOPTION_ERROR_KEYS.groups, error));
}, },
setSelectedSegment(segment) {
this.selectedSegment = segment;
},
clearSelectedSegment() {
this.selectedSegment = null;
},
}, },
}; };
</script> </script>
...@@ -126,8 +135,9 @@ export default { ...@@ -126,8 +135,9 @@ export default {
<div v-else> <div v-else>
<devops-adoption-segment-modal <devops-adoption-segment-modal
v-if="hasGroupData" v-if="hasGroupData"
:key="modalKey"
:groups="groups.nodes" :groups="groups.nodes"
:segment-id="selectedSegmentId" :segment="selectedSegment"
/> />
<div v-if="hasSegmentsData" class="gl-mt-3"> <div v-if="hasSegmentsData" class="gl-mt-3">
<div <div
...@@ -139,12 +149,20 @@ export default { ...@@ -139,12 +149,20 @@ export default {
<template #timestamp>{{ timestamp }}</template> <template #timestamp>{{ timestamp }}</template>
</gl-sprintf> </gl-sprintf>
</span> </span>
<gl-button v-gl-modal="$options.devopsSegmentModalId">{{ <gl-button v-gl-modal="$options.devopsSegmentModalId" @click="clearSelectedSegment">{{
$options.i18n.tableHeader.button $options.i18n.tableHeader.button
}}</gl-button> }}</gl-button>
</div> </div>
<devops-adoption-table :segments="devopsAdoptionSegments.nodes" /> <devops-adoption-table
:segments="devopsAdoptionSegments.nodes"
:selected-segment="selectedSegment"
@set-selected-segment="setSelectedSegment"
/>
</div> </div>
<devops-adoption-empty-state v-else :has-groups-data="hasGroupData" /> <devops-adoption-empty-state
v-else
:has-groups-data="hasGroupData"
@clear-selected-segment="clearSelectedSegment"
/>
</div> </div>
</template> </template>
...@@ -33,6 +33,7 @@ export default { ...@@ -33,6 +33,7 @@ export default {
v-gl-modal="$options.devopsSegmentModalId" v-gl-modal="$options.devopsSegmentModalId"
:disabled="!hasGroupsData" :disabled="!hasGroupsData"
variant="info" variant="info"
@click="$emit('clear-selected-segment')"
>{{ $options.i18n.button }}</gl-button >{{ $options.i18n.button }}</gl-button
> >
</template> </template>
......
...@@ -7,9 +7,10 @@ import { ...@@ -7,9 +7,10 @@ import {
GlSprintf, GlSprintf,
GlAlert, GlAlert,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { convertToGraphQLIds, TYPE_GROUP } from '~/graphql_shared/utils'; import { getIdFromGraphQLId, convertToGraphQLIds, TYPE_GROUP } from '~/graphql_shared/utils';
import * as Sentry from '~/sentry/wrapper'; import * as Sentry from '~/sentry/wrapper';
import createDevopsAdoptionSegmentMutation from '../graphql/mutations/create_devops_adoption_segment.mutation.graphql'; import createDevopsAdoptionSegmentMutation from '../graphql/mutations/create_devops_adoption_segment.mutation.graphql';
import updateDevopsAdoptionSegmentMutation from '../graphql/mutations/update_devops_adoption_segment.mutation.graphql';
import { DEVOPS_ADOPTION_STRINGS, DEVOPS_ADOPTION_SEGMENT_MODAL_ID } from '../constants'; import { DEVOPS_ADOPTION_STRINGS, DEVOPS_ADOPTION_SEGMENT_MODAL_ID } from '../constants';
import { addSegmentToCache } from '../utils/cache_updates'; import { addSegmentToCache } from '../utils/cache_updates';
...@@ -24,8 +25,8 @@ export default { ...@@ -24,8 +25,8 @@ export default {
GlAlert, GlAlert,
}, },
props: { props: {
segmentId: { segment: {
type: String, type: Object,
required: false, required: false,
default: null, default: null,
}, },
...@@ -37,8 +38,8 @@ export default { ...@@ -37,8 +38,8 @@ export default {
i18n: DEVOPS_ADOPTION_STRINGS.modal, i18n: DEVOPS_ADOPTION_STRINGS.modal,
data() { data() {
return { return {
name: '', name: this.segment?.name || '',
checkboxValues: [], checkboxValues: this.segment ? this.checkboxValuesFromSegment() : [],
loading: false, loading: false,
errors: [], errors: [],
}; };
...@@ -55,14 +56,17 @@ export default { ...@@ -55,14 +56,17 @@ export default {
}, },
primaryOptions() { primaryOptions() {
return { return {
text: this.$options.i18n.button, button: {
attributes: [ text: this.segment ? this.$options.i18n.editingButton : this.$options.i18n.addingButton,
{ attributes: [
variant: 'info', {
loading: this.loading, variant: 'info',
disabled: !this.canSubmit, loading: this.loading,
}, disabled: !this.canSubmit,
], },
],
},
callback: this.segment ? this.updateSegment : this.createSegment,
}; };
}, },
canSubmit() { canSubmit() {
...@@ -71,6 +75,9 @@ export default { ...@@ -71,6 +75,9 @@ export default {
displayError() { displayError() {
return this.errors[0]; return this.errors[0];
}, },
modalTitle() {
return this.segment ? this.$options.i18n.editingTitle : this.$options.i18n.addingTitle;
},
}, },
methods: { methods: {
async createSegment() { async createSegment() {
...@@ -98,10 +105,35 @@ export default { ...@@ -98,10 +105,35 @@ export default {
if (errors.length) { if (errors.length) {
this.errors = errors; this.errors = errors;
} else { } else {
this.name = ''; this.closeModal();
this.checkboxValues = []; }
} catch (error) {
this.errors.push(this.$options.i18n.error);
Sentry.captureException(error);
} finally {
this.loading = false;
}
},
async updateSegment() {
try {
this.loading = true;
const {
data: {
updateDevopsAdoptionSegment: { errors },
},
} = await this.$apollo.mutate({
mutation: updateDevopsAdoptionSegmentMutation,
variables: {
id: this.segment.id,
name: this.name,
groupIds: convertToGraphQLIds(TYPE_GROUP, this.checkboxValues),
},
});
this.$refs.modal.hide(); if (errors.length) {
this.errors = errors;
} else {
this.closeModal();
} }
} catch (error) { } catch (error) {
this.errors.push(this.$options.i18n.error); this.errors.push(this.$options.i18n.error);
...@@ -113,6 +145,12 @@ export default { ...@@ -113,6 +145,12 @@ export default {
clearErrors() { clearErrors() {
this.errors = []; this.errors = [];
}, },
closeModal() {
this.$refs.modal.hide();
},
checkboxValuesFromSegment() {
return this.segment.groups.map(({ id }) => getIdFromGraphQLId(id));
},
}, },
devopsSegmentModalId: DEVOPS_ADOPTION_SEGMENT_MODAL_ID, devopsSegmentModalId: DEVOPS_ADOPTION_SEGMENT_MODAL_ID,
}; };
...@@ -121,12 +159,12 @@ export default { ...@@ -121,12 +159,12 @@ export default {
<gl-modal <gl-modal
ref="modal" ref="modal"
:modal-id="$options.devopsSegmentModalId" :modal-id="$options.devopsSegmentModalId"
:title="$options.i18n.title" :title="modalTitle"
size="sm" size="sm"
scrollable scrollable
:action-primary="primaryOptions" :action-primary="primaryOptions.button"
:action-cancel="cancelOptions" :action-cancel="cancelOptions"
@primary.prevent="createSegment" @primary.prevent="primaryOptions.callback"
> >
<gl-alert v-if="errors.length" variant="danger" class="gl-mb-3" @dismiss="clearErrors"> <gl-alert v-if="errors.length" variant="danger" class="gl-mb-3" @dismiss="clearErrors">
{{ displayError }} {{ displayError }}
......
...@@ -6,6 +6,7 @@ import DevopsAdoptionDeleteModal from './devops_adoption_delete_modal.vue'; ...@@ -6,6 +6,7 @@ import DevopsAdoptionDeleteModal from './devops_adoption_delete_modal.vue';
import { import {
DEVOPS_ADOPTION_TABLE_TEST_IDS, DEVOPS_ADOPTION_TABLE_TEST_IDS,
DEVOPS_ADOPTION_STRINGS, DEVOPS_ADOPTION_STRINGS,
DEVOPS_ADOPTION_SEGMENT_MODAL_ID,
DEVOPS_ADOPTION_SEGMENT_DELETE_MODAL_ID, DEVOPS_ADOPTION_SEGMENT_DELETE_MODAL_ID,
} from '../constants'; } from '../constants';
...@@ -24,6 +25,7 @@ export default { ...@@ -24,6 +25,7 @@ export default {
DevopsAdoptionDeleteModal, DevopsAdoptionDeleteModal,
}, },
i18n: DEVOPS_ADOPTION_STRINGS.table, i18n: DEVOPS_ADOPTION_STRINGS.table,
devopsSegmentModalId: DEVOPS_ADOPTION_SEGMENT_MODAL_ID,
devopsSegmentDeleteModalId: DEVOPS_ADOPTION_SEGMENT_DELETE_MODAL_ID, devopsSegmentDeleteModalId: DEVOPS_ADOPTION_SEGMENT_DELETE_MODAL_ID,
directives: { directives: {
GlModal: GlModalDirective, GlModal: GlModalDirective,
...@@ -82,11 +84,11 @@ export default { ...@@ -82,11 +84,11 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
}, selectedSegment: {
data() { type: Object,
return { required: false,
selectedSegment: null, default: null,
}; },
}, },
methods: { methods: {
popoverContainerId(name) { popoverContainerId(name) {
...@@ -96,7 +98,7 @@ export default { ...@@ -96,7 +98,7 @@ export default {
return `popover_id_for_${name}`; return `popover_id_for_${name}`;
}, },
setSelectedSegment(segment) { setSelectedSegment(segment) {
this.selectedSegment = segment; this.$emit('set-selected-segment', segment);
}, },
}, },
}; };
...@@ -181,13 +183,22 @@ export default { ...@@ -181,13 +183,22 @@ export default {
triggers="hover focus" triggers="hover focus"
placement="left" placement="left"
> >
<gl-button <div class="gl-display-inline-flex gl-flex-direction-column">
v-gl-modal="$options.devopsSegmentDeleteModalId" <gl-button
category="tertiary" v-gl-modal="$options.devopsSegmentModalId"
variant="danger" category="tertiary"
@click="setSelectedSegment(item)" class="gl-w-max-content"
>{{ $options.i18n.deleteButton }}</gl-button @click="setSelectedSegment(item)"
> >{{ $options.i18n.editButton }}</gl-button
>
<gl-button
v-gl-modal="$options.devopsSegmentDeleteModalId"
category="tertiary"
variant="danger"
@click="setSelectedSegment(item)"
>{{ $options.i18n.deleteButton }}</gl-button
>
</div>
</gl-popover> </gl-popover>
</div> </div>
</div> </div>
......
...@@ -36,8 +36,10 @@ export const DEVOPS_ADOPTION_STRINGS = { ...@@ -36,8 +36,10 @@ export const DEVOPS_ADOPTION_STRINGS = {
button: s__('DevopsAdoption|Add new segment'), button: s__('DevopsAdoption|Add new segment'),
}, },
modal: { modal: {
title: s__('DevopsAdoption|New segment'), addingTitle: s__('DevopsAdoption|New segment'),
button: s__('DevopsAdoption|Create new segment'), editingTitle: s__('DevopsAdoption|Edit segment'),
addingButton: s__('DevopsAdoption|Create new segment'),
editingButton: s__('DevopsAdoption|Save changes'),
cancel: __('Cancel'), cancel: __('Cancel'),
namePlaceholder: s__('DevopsAdoption|My segment'), namePlaceholder: s__('DevopsAdoption|My segment'),
nameLabel: s__('DevopsAdoption|Name'), nameLabel: s__('DevopsAdoption|Name'),
...@@ -46,6 +48,7 @@ export const DEVOPS_ADOPTION_STRINGS = { ...@@ -46,6 +48,7 @@ export const DEVOPS_ADOPTION_STRINGS = {
error: s__('DevopsAdoption|An error occured while saving the segment. Please try again.'), error: s__('DevopsAdoption|An error occured while saving the segment. Please try again.'),
}, },
table: { table: {
editButton: s__('DevopsAdoption|Edit segment'),
deleteButton: s__('DevopsAdoption|Delete segment'), deleteButton: s__('DevopsAdoption|Delete segment'),
}, },
deleteModal: { deleteModal: {
......
mutation($id: AnalyticsDevopsAdoptionSegmentID!, $name: String!, $groupIds: [GroupID!]!) {
updateDevopsAdoptionSegment(input: { id: $id, name: $name, groupIds: $groupIds }) {
segment {
id
name
groups {
id
}
latestSnapshot {
issueOpened
mergeRequestOpened
mergeRequestApproved
runnerConfigured
pipelineSucceeded
deploySucceeded
securityScanSucceeded
recordedAt
}
}
errors
}
}
...@@ -3,6 +3,9 @@ query devopsAdoptionSegments { ...@@ -3,6 +3,9 @@ query devopsAdoptionSegments {
nodes { nodes {
id id
name name
groups {
id
}
latestSnapshot { latestSnapshot {
issueOpened issueOpened
mergeRequestOpened mergeRequestOpened
......
...@@ -14,6 +14,7 @@ import { ...@@ -14,6 +14,7 @@ import {
segmentName, segmentName,
genericErrorMessage, genericErrorMessage,
dataErrorMessage, dataErrorMessage,
devopsAdoptionSegmentsData,
} from '../mock_data'; } from '../mock_data';
const mockEvent = { preventDefault: jest.fn() }; const mockEvent = { preventDefault: jest.fn() };
...@@ -22,28 +23,33 @@ const mutate = jest.fn().mockResolvedValue({ ...@@ -22,28 +23,33 @@ const mutate = jest.fn().mockResolvedValue({
createDevopsAdoptionSegment: { createDevopsAdoptionSegment: {
errors: [], errors: [],
}, },
}, updateDevopsAdoptionSegment: {
}); errors: [],
const mutateWithDataErrors = jest.fn().mockResolvedValue({
data: {
createDevopsAdoptionSegment: {
errors: [dataErrorMessage],
}, },
}, },
}); });
const mutateWithDataErrors = segment =>
jest.fn().mockResolvedValue({
data: {
[segment ? 'updateDevopsAdoptionSegment' : 'createDevopsAdoptionSegment']: {
errors: [dataErrorMessage],
},
},
});
const mutateLoading = jest.fn().mockResolvedValue(new Promise(() => {})); const mutateLoading = jest.fn().mockResolvedValue(new Promise(() => {}));
const mutateWithErrors = jest.fn().mockRejectedValue(genericErrorMessage); const mutateWithErrors = jest.fn().mockRejectedValue(genericErrorMessage);
describe('DevopsAdoptionSegmentModal', () => { describe('DevopsAdoptionSegmentModal', () => {
let wrapper; let wrapper;
const createComponent = ({ mutationMock = mutate } = {}) => { const createComponent = ({ mutationMock = mutate, segment = null } = {}) => {
const $apollo = { const $apollo = {
mutate: mutationMock, mutate: mutationMock,
}; };
wrapper = shallowMount(DevopsAdoptionSegmentModal, { wrapper = shallowMount(DevopsAdoptionSegmentModal, {
propsData: { propsData: {
segment,
groups: groupNodes, groups: groupNodes,
}, },
stubs: { stubs: {
...@@ -151,110 +157,123 @@ describe('DevopsAdoptionSegmentModal', () => { ...@@ -151,110 +157,123 @@ describe('DevopsAdoptionSegmentModal', () => {
}, },
); );
describe('submitting the form', () => { describe.each`
describe('while waiting for the mutation', () => { action | segment | additionalData
beforeEach(() => { ${'creating a new segment'} | ${null} | ${{ checkboxValues: groupIds, name: segmentName }}
createComponent({ mutationMock: mutateLoading }); ${'updating an existing segment'} | ${devopsAdoptionSegmentsData.nodes[0]} | ${{}}
`('handles the form submission correctly when $action', ({ segment, additionalData }) => {
describe('submitting the form', () => {
describe('while waiting for the mutation', () => {
beforeEach(() => {
createComponent({ mutationMock: mutateLoading, segment });
wrapper.setData({ checkboxValues: [1], name: segmentName }); wrapper.setData(additionalData);
}); });
it('disables the form inputs', async () => { it('disables the form inputs', async () => {
const checkboxes = findByTestId('groups'); const checkboxes = findByTestId('groups');
const name = findByTestId('name'); const name = findByTestId('name');
expect(checkboxes.attributes('disabled')).not.toBeDefined(); expect(checkboxes.attributes('disabled')).not.toBeDefined();
expect(name.attributes('disabled')).not.toBeDefined(); expect(name.attributes('disabled')).not.toBeDefined();
findModal().vm.$emit('primary', mockEvent); findModal().vm.$emit('primary', mockEvent);
await waitForPromises(); await nextTick();
expect(checkboxes.attributes('disabled')).toBeDefined(); expect(checkboxes.attributes('disabled')).toBeDefined();
expect(name.attributes('disabled')).toBeDefined(); expect(name.attributes('disabled')).toBeDefined();
}); });
it('disables the cancel button', async () => { it('disables the cancel button', async () => {
expect(cancelButtonDisabledState()).toBe(false); expect(cancelButtonDisabledState()).toBe(false);
findModal().vm.$emit('primary', mockEvent); findModal().vm.$emit('primary', mockEvent);
await waitForPromises(); await nextTick();
expect(cancelButtonDisabledState()).toBe(true); expect(cancelButtonDisabledState()).toBe(true);
}); });
it('sets the action button state to loading', async () => { it('sets the action button state to loading', async () => {
expect(actionButtonLoadingState()).toBe(false); expect(actionButtonLoadingState()).toBe(false);
findModal().vm.$emit('primary', mockEvent); findModal().vm.$emit('primary', mockEvent);
await waitForPromises(); await nextTick();
expect(actionButtonLoadingState()).toBe(true); expect(actionButtonLoadingState()).toBe(true);
});
}); });
});
describe('successful submission', () => { describe('successful submission', () => {
beforeEach(async () => { beforeEach(() => {
createComponent(); createComponent({ segment });
wrapper.setData({ checkboxValues: groupIds, name: segmentName }); wrapper.setData(additionalData);
wrapper.vm.$refs.modal.hide = jest.fn();
findModal().vm.$emit('primary', mockEvent); wrapper.vm.$refs.modal.hide = jest.fn();
await waitForPromises(); findModal().vm.$emit('primary', mockEvent);
});
it('submits the correct request variables', async () => {
const variables = segment
? {
id: segment.id,
groupIds: [groupGids[0]],
name: segment.name,
}
: {
groupIds: groupGids,
name: segmentName,
};
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({
variables,
}),
);
});
it('closes the modal after a successful mutation', async () => {
expect(wrapper.vm.$refs.modal.hide).toHaveBeenCalled();
});
}); });
it('submits the correct request variables', async () => { describe('error handling', () => {
expect(mutate).toHaveBeenCalledWith( it.each`
expect.objectContaining({ errorType | errorLocation | mutationSpy | message
variables: { ${'generic'} | ${'top level'} | ${mutateWithErrors} | ${genericErrorMessage}
groupIds: groupGids, ${'specific'} | ${'data'} | ${mutateWithDataErrors(segment)} | ${dataErrorMessage}
name: segmentName, `(
}, 'displays a $errorType error if the mutation has a $errorLocation error',
}), async ({ mutationSpy, message }) => {
); createComponent({ mutationMock: mutationSpy, segment });
});
it('closes the modal after a successful mutation', async () => { findModal().vm.$emit('primary', mockEvent);
expect(wrapper.vm.$refs.modal.hide).toHaveBeenCalled();
});
});
describe('error handling', () => { await waitForPromises();
it.each`
errorType | errorLocation | mutationSpy | message
${'generic'} | ${'top level'} | ${mutateWithErrors} | ${genericErrorMessage}
${'specific'} | ${'data'} | ${mutateWithDataErrors} | ${dataErrorMessage}
`(
'displays a $errorType error if the mutation has a $errorLocation error',
async ({ mutationSpy, message }) => {
createComponent({ mutationMock: mutationSpy });
findModal().vm.$emit('primary', mockEvent); const alert = findAlert();
await waitForPromises(); expect(alert.exists()).toBe(true);
expect(alert.props('variant')).toBe('danger');
const alert = findAlert(); expect(alert.text()).toBe(message);
},
expect(alert.exists()).toBe(true); );
expect(alert.props('variant')).toBe('danger');
expect(alert.text()).toBe(message);
},
);
it('calls sentry on top level error', async () => { it('calls sentry on top level error', async () => {
jest.spyOn(Sentry, 'captureException'); jest.spyOn(Sentry, 'captureException');
createComponent({ mutationMock: mutateWithErrors }); createComponent({ mutationMock: mutateWithErrors, segment });
findModal().vm.$emit('primary', mockEvent); findModal().vm.$emit('primary', mockEvent);
await waitForPromises(); await waitForPromises();
expect(Sentry.captureException.mock.calls[0][0]).toBe(genericErrorMessage); expect(Sentry.captureException.mock.calls[0][0]).toBe(genericErrorMessage);
});
}); });
}); });
}); });
......
export const groupData = [{ id: 'foo', full_name: 'Foo' }, { id: 'bar', full_name: 'Bar' }]; export const groupData = [{ id: '1', full_name: 'Foo' }, { id: '2', full_name: 'Bar' }];
export const pageData = { export const pageData = {
'x-next-page': 2, 'x-next-page': 2,
...@@ -8,23 +8,23 @@ export const groupNodes = [ ...@@ -8,23 +8,23 @@ export const groupNodes = [
{ {
__typename: 'Group', __typename: 'Group',
full_name: 'Foo', full_name: 'Foo',
id: 'foo', id: '1',
}, },
{ {
__typename: 'Group', __typename: 'Group',
full_name: 'Bar', full_name: 'Bar',
id: 'bar', id: '2',
}, },
]; ];
export const groupIds = ['foo', 'bar']; export const groupIds = ['1', '2'];
export const groupGids = ['gid://gitlab/Group/foo', 'gid://gitlab/Group/bar']; export const groupGids = ['gid://gitlab/Group/1', 'gid://gitlab/Group/2'];
export const nextGroupNode = { export const nextGroupNode = {
__typename: 'Group', __typename: 'Group',
full_name: 'Baz', full_name: 'Baz',
id: 'baz', id: '3',
}; };
export const groupPageInfo = { export const groupPageInfo = {
...@@ -36,6 +36,11 @@ export const devopsAdoptionSegmentsData = { ...@@ -36,6 +36,11 @@ export const devopsAdoptionSegmentsData = {
{ {
name: 'Segment 1', name: 'Segment 1',
id: 1, id: 1,
groups: [
{
id: 'gid://gitlab/Group/1',
},
],
latestSnapshot: { latestSnapshot: {
issueOpened: true, issueOpened: true,
mergeRequestOpened: true, mergeRequestOpened: true,
......
...@@ -9634,6 +9634,9 @@ msgstr "" ...@@ -9634,6 +9634,9 @@ msgstr ""
msgid "DevopsAdoption|DevOps adoption uses segments to track adoption across key features. Segments are a way to track multiple related projects and groups at once. For example, you could create a segment for the engineering department or a particular product team." msgid "DevopsAdoption|DevOps adoption uses segments to track adoption across key features. Segments are a way to track multiple related projects and groups at once. For example, you could create a segment for the engineering department or a particular product team."
msgstr "" msgstr ""
msgid "DevopsAdoption|Edit segment"
msgstr ""
msgid "DevopsAdoption|Feature adoption is based on usage in the last calendar month. Last updated: %{timestamp}." msgid "DevopsAdoption|Feature adoption is based on usage in the last calendar month. Last updated: %{timestamp}."
msgstr "" msgstr ""
...@@ -9658,6 +9661,9 @@ msgstr "" ...@@ -9658,6 +9661,9 @@ msgstr ""
msgid "DevopsAdoption|Runners" msgid "DevopsAdoption|Runners"
msgstr "" msgstr ""
msgid "DevopsAdoption|Save changes"
msgstr ""
msgid "DevopsAdoption|Scanning" msgid "DevopsAdoption|Scanning"
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment