Commit 60ee7e3c 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,7 +56,8 @@ export default { ...@@ -55,7 +56,8 @@ export default {
}, },
primaryOptions() { primaryOptions() {
return { return {
text: this.$options.i18n.button, button: {
text: this.segment ? this.$options.i18n.editingButton : this.$options.i18n.addingButton,
attributes: [ attributes: [
{ {
variant: 'info', variant: 'info',
...@@ -63,6 +65,8 @@ export default { ...@@ -63,6 +65,8 @@ export default {
disabled: !this.canSubmit, 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: {
type: Object,
required: false,
default: null,
}, },
data() {
return {
selectedSegment: 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);
}, },
}, },
}; };
...@@ -180,6 +182,14 @@ export default { ...@@ -180,6 +182,14 @@ export default {
:container="popoverContainerId(item.name)" :container="popoverContainerId(item.name)"
triggers="hover focus" triggers="hover focus"
placement="left" placement="left"
>
<div class="gl-display-inline-flex gl-flex-direction-column">
<gl-button
v-gl-modal="$options.devopsSegmentModalId"
category="tertiary"
class="gl-w-max-content"
@click="setSelectedSegment(item)"
>{{ $options.i18n.editButton }}</gl-button
> >
<gl-button <gl-button
v-gl-modal="$options.devopsSegmentDeleteModalId" v-gl-modal="$options.devopsSegmentDeleteModalId"
...@@ -188,6 +198,7 @@ export default { ...@@ -188,6 +198,7 @@ export default {
@click="setSelectedSegment(item)" @click="setSelectedSegment(item)"
>{{ $options.i18n.deleteButton }}</gl-button >{{ $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({ const mutateWithDataErrors = segment =>
jest.fn().mockResolvedValue({
data: { data: {
createDevopsAdoptionSegment: { [segment ? 'updateDevopsAdoptionSegment' : 'createDevopsAdoptionSegment']: {
errors: [dataErrorMessage], 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,12 +157,17 @@ describe('DevopsAdoptionSegmentModal', () => { ...@@ -151,12 +157,17 @@ describe('DevopsAdoptionSegmentModal', () => {
}, },
); );
describe.each`
action | segment | additionalData
${'creating a new segment'} | ${null} | ${{ checkboxValues: groupIds, name: segmentName }}
${'updating an existing segment'} | ${devopsAdoptionSegmentsData.nodes[0]} | ${{}}
`('handles the form submission correctly when $action', ({ segment, additionalData }) => {
describe('submitting the form', () => { describe('submitting the form', () => {
describe('while waiting for the mutation', () => { describe('while waiting for the mutation', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ mutationMock: mutateLoading }); 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 () => {
...@@ -168,7 +179,7 @@ describe('DevopsAdoptionSegmentModal', () => { ...@@ -168,7 +179,7 @@ describe('DevopsAdoptionSegmentModal', () => {
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();
...@@ -179,7 +190,7 @@ describe('DevopsAdoptionSegmentModal', () => { ...@@ -179,7 +190,7 @@ describe('DevopsAdoptionSegmentModal', () => {
findModal().vm.$emit('primary', mockEvent); findModal().vm.$emit('primary', mockEvent);
await waitForPromises(); await nextTick();
expect(cancelButtonDisabledState()).toBe(true); expect(cancelButtonDisabledState()).toBe(true);
}); });
...@@ -189,31 +200,38 @@ describe('DevopsAdoptionSegmentModal', () => { ...@@ -189,31 +200,38 @@ describe('DevopsAdoptionSegmentModal', () => {
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(additionalData);
wrapper.setData({ checkboxValues: groupIds, name: segmentName });
wrapper.vm.$refs.modal.hide = jest.fn(); wrapper.vm.$refs.modal.hide = jest.fn();
findModal().vm.$emit('primary', mockEvent); findModal().vm.$emit('primary', mockEvent);
await waitForPromises();
}); });
it('submits the correct request variables', async () => { it('submits the correct request variables', async () => {
expect(mutate).toHaveBeenCalledWith( const variables = segment
expect.objectContaining({ ? {
variables: { id: segment.id,
groupIds: [groupGids[0]],
name: segment.name,
}
: {
groupIds: groupGids, groupIds: groupGids,
name: segmentName, name: segmentName,
}, };
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({
variables,
}), }),
); );
}); });
...@@ -227,11 +245,11 @@ describe('DevopsAdoptionSegmentModal', () => { ...@@ -227,11 +245,11 @@ describe('DevopsAdoptionSegmentModal', () => {
it.each` it.each`
errorType | errorLocation | mutationSpy | message errorType | errorLocation | mutationSpy | message
${'generic'} | ${'top level'} | ${mutateWithErrors} | ${genericErrorMessage} ${'generic'} | ${'top level'} | ${mutateWithErrors} | ${genericErrorMessage}
${'specific'} | ${'data'} | ${mutateWithDataErrors} | ${dataErrorMessage} ${'specific'} | ${'data'} | ${mutateWithDataErrors(segment)} | ${dataErrorMessage}
`( `(
'displays a $errorType error if the mutation has a $errorLocation error', 'displays a $errorType error if the mutation has a $errorLocation error',
async ({ mutationSpy, message }) => { async ({ mutationSpy, message }) => {
createComponent({ mutationMock: mutationSpy }); createComponent({ mutationMock: mutationSpy, segment });
findModal().vm.$emit('primary', mockEvent); findModal().vm.$emit('primary', mockEvent);
...@@ -248,7 +266,7 @@ describe('DevopsAdoptionSegmentModal', () => { ...@@ -248,7 +266,7 @@ describe('DevopsAdoptionSegmentModal', () => {
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);
...@@ -258,4 +276,5 @@ describe('DevopsAdoptionSegmentModal', () => { ...@@ -258,4 +276,5 @@ describe('DevopsAdoptionSegmentModal', () => {
}); });
}); });
}); });
});
}); });
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