Commit 7773811b authored by Brandon Labuschagne's avatar Brandon Labuschagne Committed by Ezekiel Kigbo

Introduce edit segment functionality

This reuses the add segment modal, by prepopulating
the form using an existing segment.
parent d5edc1b6
...@@ -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,
......
...@@ -9631,6 +9631,9 @@ msgstr "" ...@@ -9631,6 +9631,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 ""
...@@ -9655,6 +9658,9 @@ msgstr "" ...@@ -9655,6 +9658,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