Commit b9dc6ad6 authored by Jiaan Louw's avatar Jiaan Louw Committed by Jacques Erasmus

Update compliance report and filter

- Update report empty state to use GlTable empty text
- Update report to only show loading when initializing
- Update report table to show busy when fetching
- Update report subheading to include help link
- Resolve a filter bug where the first change would reset the URL
  query params.
parent dcddbf83
...@@ -6,7 +6,6 @@ import { queryToObject } from '~/lib/utils/url_utility'; ...@@ -6,7 +6,6 @@ import { queryToObject } from '~/lib/utils/url_utility';
import resolvers from './graphql/resolvers'; import resolvers from './graphql/resolvers';
import ComplianceDashboard from './components/dashboard.vue'; import ComplianceDashboard from './components/dashboard.vue';
import ComplianceReport from './components/report.vue'; import ComplianceReport from './components/report.vue';
import { parseViolationsQuery } from './utils';
export default () => { export default () => {
const el = document.getElementById('js-compliance-report'); const el = document.getElementById('js-compliance-report');
...@@ -26,9 +25,7 @@ export default () => { ...@@ -26,9 +25,7 @@ export default () => {
defaultClient: createDefaultClient(resolvers), defaultClient: createDefaultClient(resolvers),
}); });
const defaultQuery = parseViolationsQuery( const defaultQuery = queryToObject(window.location.search, { gatherArrays: true });
queryToObject(window.location.search, { gatherArrays: true }),
);
return new Vue({ return new Vue({
el, el,
...@@ -36,7 +33,6 @@ export default () => { ...@@ -36,7 +33,6 @@ export default () => {
render: (createElement) => render: (createElement) =>
createElement(ComplianceReport, { createElement(ComplianceReport, {
props: { props: {
emptyStateSvgPath,
mergeCommitsCsvExportPath, mergeCommitsCsvExportPath,
groupPath, groupPath,
defaultQuery, defaultQuery,
......
<script> <script>
import { GlAlert, GlLoadingIcon, GlTable } from '@gitlab/ui'; import { GlAlert, GlLoadingIcon, GlTable, GlLink } from '@gitlab/ui';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import { __ } from '~/locale'; import { s__, __ } from '~/locale';
import { thWidthClass } from '~/lib/utils/table_utility'; import { thWidthClass } from '~/lib/utils/table_utility';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
import UrlSync from '~/vue_shared/components/url_sync.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue';
import { helpPagePath } from '~/helpers/help_page_helper';
import complianceViolationsQuery from '../graphql/compliance_violations.query.graphql'; import complianceViolationsQuery from '../graphql/compliance_violations.query.graphql';
import { mapViolations } from '../graphql/mappers'; import { mapViolations } from '../graphql/mappers';
import EmptyState from './empty_state.vue';
import MergeCommitsExportButton from './merge_requests/merge_commits_export_button.vue'; import MergeCommitsExportButton from './merge_requests/merge_commits_export_button.vue';
import MergeRequestDrawer from './drawer.vue'; import MergeRequestDrawer from './drawer.vue';
import ViolationReason from './violations/reason.vue'; import ViolationReason from './violations/reason.vue';
...@@ -17,10 +17,10 @@ import ViolationFilter from './violations/filter.vue'; ...@@ -17,10 +17,10 @@ import ViolationFilter from './violations/filter.vue';
export default { export default {
name: 'ComplianceReport', name: 'ComplianceReport',
components: { components: {
EmptyState,
GlAlert, GlAlert,
GlLoadingIcon, GlLoadingIcon,
GlTable, GlTable,
GlLink,
MergeCommitsExportButton, MergeCommitsExportButton,
MergeRequestDrawer, MergeRequestDrawer,
ViolationReason, ViolationReason,
...@@ -29,10 +29,6 @@ export default { ...@@ -29,10 +29,6 @@ export default {
UrlSync, UrlSync,
}, },
props: { props: {
emptyStateSvgPath: {
type: String,
required: true,
},
mergeCommitsCsvExportPath: { mergeCommitsCsvExportPath: {
type: String, type: String,
required: false, required: false,
...@@ -78,9 +74,6 @@ export default { ...@@ -78,9 +74,6 @@ export default {
isLoading() { isLoading() {
return this.$apollo.queries.violations.loading; return this.$apollo.queries.violations.loading;
}, },
hasViolations() {
return this.violations.length > 0;
},
hasMergeCommitsCsvExportPath() { hasMergeCommitsCsvExportPath() {
return this.mergeCommitsCsvExportPath !== ''; return this.mergeCommitsCsvExportPath !== '';
}, },
...@@ -92,10 +85,10 @@ export default { ...@@ -92,10 +85,10 @@ export default {
if (!mergeRequest || (this.showDrawer && id === this.drawerMergeRequest.id)) { if (!mergeRequest || (this.showDrawer && id === this.drawerMergeRequest.id)) {
this.closeDrawer(); this.closeDrawer();
} else { } else {
this.openDrawer(id, mergeRequest, project); this.openDrawer(mergeRequest, project);
} }
}, },
openDrawer(id, mergeRequest, project) { openDrawer(mergeRequest, project) {
this.showDrawer = true; this.showDrawer = true;
this.drawerMergeRequest = mergeRequest; this.drawerMergeRequest = mergeRequest;
this.drawerProject = project; this.drawerProject = project;
...@@ -143,16 +136,22 @@ export default { ...@@ -143,16 +136,22 @@ export default {
subheading: __( subheading: __(
'The compliance report shows the merge request violations merged in protected environments.', 'The compliance report shows the merge request violations merged in protected environments.',
), ),
queryError: __( queryError: __('Retrieving the compliance report failed. Refresh the page and try again.'),
'Retrieving the compliance report failed. Please refresh the page and try again.', noViolationsFound: s__('ComplianceReport|No violations found'),
), learnMore: __('Learn more.'),
}, },
documentationPath: helpPagePath('user/compliance/compliance_report/index.md', {
anchor: 'approval-status-and-separation-of-duties',
}),
DRAWER_Z_INDEX, DRAWER_Z_INDEX,
}; };
</script> </script>
<template> <template>
<section> <section>
<gl-alert v-if="queryError" variant="danger" class="gl-mt-3" :dismissible="false">
{{ $options.i18n.queryError }}
</gl-alert>
<header class="gl-mb-6"> <header class="gl-mb-6">
<div class="gl-mt-5 d-flex"> <div class="gl-mt-5 d-flex">
<h2 class="gl-flex-grow-1 gl-my-0">{{ $options.i18n.heading }}</h2> <h2 class="gl-flex-grow-1 gl-my-0">{{ $options.i18n.heading }}</h2>
...@@ -161,46 +160,47 @@ export default { ...@@ -161,46 +160,47 @@ export default {
:merge-commits-csv-export-path="mergeCommitsCsvExportPath" :merge-commits-csv-export-path="mergeCommitsCsvExportPath"
/> />
</div> </div>
<p class="gl-mt-5">{{ $options.i18n.subheading }}</p> <p class="gl-mt-5" data-testid="subheading">
{{ $options.i18n.subheading }}
<gl-link :href="$options.documentationPath" target="_blank">{{
$options.i18n.learnMore
}}</gl-link>
</p>
</header> </header>
<gl-loading-icon v-if="isLoading" size="xl" /> <violation-filter
<gl-alert :group-path="groupPath"
v-else-if="queryError" :default-query="defaultQuery"
variant="danger" @filters-changed="updateUrlQuery"
:dismissible="false"
:title="$options.i18n.queryError"
/> />
<template v-else-if="hasViolations"> <gl-table
<violation-filter ref="table"
:group-path="groupPath" :fields="$options.fields"
:default-query="defaultQuery" :items="violations"
@filters-changed="updateUrlQuery" :busy="isLoading"
/> :empty-text="$options.i18n.noViolationsFound"
<gl-table :selectable="true"
ref="table" show-empty
:fields="$options.fields" head-variant="white"
:items="violations" stacked="lg"
head-variant="white" select-mode="single"
stacked="lg" hover
select-mode="single" selected-variant="primary"
selectable thead-class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"
hover @row-selected="toggleDrawer"
selected-variant="primary" >
thead-class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100" <template #cell(reason)="{ item: { reason, violatingUser } }">
@row-selected="toggleDrawer" <violation-reason :reason="reason" :user="violatingUser" />
> </template>
<template #cell(reason)="{ item: { reason, violatingUser } }"> <template #cell(mergeRequest)="{ item: { mergeRequest } }">
<violation-reason :reason="reason" :user="violatingUser" /> {{ mergeRequest.title }}
</template> </template>
<template #cell(mergeRequest)="{ item: { mergeRequest } }"> <template #cell(mergedAt)="{ item: { mergeRequest } }">
{{ mergeRequest.title }} <time-ago-tooltip :time="mergeRequest.mergedAt" />
</template> </template>
<template #cell(mergedAt)="{ item: { mergeRequest } }"> <template #table-busy>
<time-ago-tooltip :time="mergeRequest.mergedAt" /> <gl-loading-icon size="lg" color="dark" class="mt-3" />
</template> </template>
</gl-table> </gl-table>
</template>
<empty-state v-else :image-path="emptyStateSvgPath" />
<merge-request-drawer <merge-request-drawer
:show-drawer="showDrawer" :show-drawer="showDrawer"
:merge-request="drawerMergeRequest" :merge-request="drawerMergeRequest"
......
...@@ -7,6 +7,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; ...@@ -7,6 +7,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale'; import { __ } from '~/locale';
import getGroupProjects from '../../graphql/violation_group_projects.query.graphql'; import getGroupProjects from '../../graphql/violation_group_projects.query.graphql';
import { CURRENT_DATE } from '../../../audit_events/constants'; import { CURRENT_DATE } from '../../../audit_events/constants';
import { convertProjectIdsToGraphQl } from '../../utils';
export default { export default {
components: { components: {
...@@ -30,7 +31,7 @@ export default { ...@@ -30,7 +31,7 @@ export default {
}, },
data() { data() {
return { return {
filterQuery: {}, filterQuery: { ...this.defaultQuery },
defaultProjects: [], defaultProjects: [],
loadingDefaultProjects: false, loadingDefaultProjects: false,
}; };
...@@ -47,7 +48,8 @@ export default { ...@@ -47,7 +48,8 @@ export default {
}, },
async created() { async created() {
if (this.showProjectFilter && this.defaultQuery.projectIds?.length > 0) { if (this.showProjectFilter && this.defaultQuery.projectIds?.length > 0) {
this.defaultProjects = await this.fetchProjects(this.defaultQuery.projectIds); const projectIds = convertProjectIdsToGraphQl(this.defaultQuery.projectIds);
this.defaultProjects = await this.fetchProjects(projectIds);
} }
}, },
methods: { methods: {
......
...@@ -16,10 +16,8 @@ export const mapDashboardToDrawerData = (mergeRequest) => ({ ...@@ -16,10 +16,8 @@ export const mapDashboardToDrawerData = (mergeRequest) => ({
}, },
}); });
export const parseViolationsQuery = ({ projectIds = [], ...rest }) => ({ export const convertProjectIdsToGraphQl = (projectIds) =>
projectIds: convertToGraphQLIds( convertToGraphQLIds(
TYPE_PROJECT, TYPE_PROJECT,
projectIds.filter((id) => Boolean(id)), projectIds.filter((id) => Boolean(id)),
), );
...rest,
});
import { GlAlert, GlLoadingIcon, GlTable } from '@gitlab/ui'; import { GlAlert, GlLoadingIcon, GlTable, GlLink } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils'; import { mount, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import Vue, { nextTick } from 'vue'; import Vue, { nextTick } from 'vue';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import ComplianceReport from 'ee/compliance_dashboard/components/report.vue'; import ComplianceReport from 'ee/compliance_dashboard/components/report.vue';
import EmptyState from 'ee/compliance_dashboard/components/empty_state.vue';
import MergeRequestDrawer from 'ee/compliance_dashboard/components/drawer.vue'; import MergeRequestDrawer from 'ee/compliance_dashboard/components/drawer.vue';
import MergeCommitsExportButton from 'ee/compliance_dashboard/components/merge_requests/merge_commits_export_button.vue'; import MergeCommitsExportButton from 'ee/compliance_dashboard/components/merge_requests/merge_commits_export_button.vue';
import ViolationReason from 'ee/compliance_dashboard/components/violations/reason.vue'; import ViolationReason from 'ee/compliance_dashboard/components/violations/reason.vue';
...@@ -25,7 +25,6 @@ describe('ComplianceReport component', () => { ...@@ -25,7 +25,6 @@ describe('ComplianceReport component', () => {
let mockResolver; let mockResolver;
const mergeCommitsCsvExportPath = '/csv'; const mergeCommitsCsvExportPath = '/csv';
const emptyStateSvgPath = 'empty.svg';
const groupPath = 'group-path'; const groupPath = 'group-path';
const defaultQuery = { const defaultQuery = {
projectIds: ['gid://gitlab/Project/20'], projectIds: ['gid://gitlab/Project/20'],
...@@ -34,11 +33,11 @@ describe('ComplianceReport component', () => { ...@@ -34,11 +33,11 @@ describe('ComplianceReport component', () => {
}; };
const mockGraphQlError = new Error('GraphQL networkError'); const mockGraphQlError = new Error('GraphQL networkError');
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findSubheading = () => wrapper.findByTestId('subheading');
const findErrorMessage = () => wrapper.findComponent(GlAlert); const findErrorMessage = () => wrapper.findComponent(GlAlert);
const findViolationsTable = () => wrapper.findComponent(GlTable); const findViolationsTable = () => wrapper.findComponent(GlTable);
const findTableLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findMergeRequestDrawer = () => wrapper.findComponent(MergeRequestDrawer); const findMergeRequestDrawer = () => wrapper.findComponent(MergeRequestDrawer);
const findEmptyState = () => wrapper.findComponent(EmptyState);
const findMergeCommitsExportButton = () => wrapper.findComponent(MergeCommitsExportButton); const findMergeCommitsExportButton = () => wrapper.findComponent(MergeCommitsExportButton);
const findViolationReason = () => wrapper.findComponent(ViolationReason); const findViolationReason = () => wrapper.findComponent(ViolationReason);
const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip); const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip);
...@@ -60,20 +59,22 @@ describe('ComplianceReport component', () => { ...@@ -60,20 +59,22 @@ describe('ComplianceReport component', () => {
} }
const createComponent = (mountFn = shallowMount, props = {}) => { const createComponent = (mountFn = shallowMount, props = {}) => {
return mountFn(ComplianceReport, { return extendedWrapper(
apolloProvider: createMockApolloProvider(), mountFn(ComplianceReport, {
propsData: { apolloProvider: createMockApolloProvider(),
mergeCommitsCsvExportPath, propsData: {
emptyStateSvgPath, mergeCommitsCsvExportPath,
groupPath, groupPath,
defaultQuery, defaultQuery,
...props, ...props,
}, },
stubs: { stubs: {
GlTable: false, GlLink,
ViolationFilter: stubComponent(ViolationFilter), GlTable: false,
}, ViolationFilter: stubComponent(ViolationFilter),
}); },
}),
);
}; };
afterEach(() => { afterEach(() => {
...@@ -81,16 +82,40 @@ describe('ComplianceReport component', () => { ...@@ -81,16 +82,40 @@ describe('ComplianceReport component', () => {
mockResolver = null; mockResolver = null;
}); });
describe('loading', () => { describe('default behavior', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent(); wrapper = createComponent();
}); });
it('renders the loading icon', () => { it('renders the subheading with a help link', () => {
expect(findLoadingIcon().exists()).toBe(true); const helpLink = findSubheading().find(GlLink);
expect(findSubheading().text()).toContain(
'The compliance report shows the merge request violations merged in protected environments.',
);
expect(helpLink.text()).toBe('Learn more.');
expect(helpLink.attributes('href')).toBe(
'/help/user/compliance/compliance_report/index.md#approval-status-and-separation-of-duties',
);
});
it('renders the merge commit export button', () => {
expect(findMergeCommitsExportButton().exists()).toBe(true);
});
it('does not render an error message', () => {
expect(findErrorMessage().exists()).toBe(false); expect(findErrorMessage().exists()).toBe(false);
expect(findViolationsTable().exists()).toBe(false); });
expect(findViolationFilter().exists()).toBe(false); });
describe('when initializing', () => {
beforeEach(() => {
wrapper = createComponent(mount);
});
it('renders the table loading icon', () => {
expect(findViolationsTable().exists()).toBe(true);
expect(findTableLoadingIcon().exists()).toBe(true);
}); });
}); });
...@@ -104,10 +129,9 @@ describe('ComplianceReport component', () => { ...@@ -104,10 +129,9 @@ describe('ComplianceReport component', () => {
it('renders the error message', async () => { it('renders the error message', async () => {
await waitForPromises(); await waitForPromises();
expect(findLoadingIcon().exists()).toBe(false);
expect(findErrorMessage().exists()).toBe(true); expect(findErrorMessage().exists()).toBe(true);
expect(findErrorMessage().props('title')).toBe( expect(findErrorMessage().text()).toBe(
'Retrieving the compliance report failed. Please refresh the page and try again.', 'Retrieving the compliance report failed. Refresh the page and try again.',
); );
expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(mockGraphQlError); expect(Sentry.captureException.mock.calls[0][0].networkError).toBe(mockGraphQlError);
}); });
...@@ -121,14 +145,8 @@ describe('ComplianceReport component', () => { ...@@ -121,14 +145,8 @@ describe('ComplianceReport component', () => {
return waitForPromises(); return waitForPromises();
}); });
it('renders the merge commit export button', () => { it('does not render the table loading icon', () => {
expect(findMergeCommitsExportButton().exists()).toBe(true); expect(findTableLoadingIcon().exists()).toBe(false);
});
it('renders the violations table', async () => {
expect(findLoadingIcon().exists()).toBe(false);
expect(findErrorMessage().exists()).toBe(false);
expect(findViolationsTable().exists()).toBe(true);
}); });
it('has the correct table headers', () => { it('has the correct table headers', () => {
...@@ -261,18 +279,13 @@ describe('ComplianceReport component', () => { ...@@ -261,18 +279,13 @@ describe('ComplianceReport component', () => {
nodes: [], nodes: [],
}, },
}); });
wrapper = createComponent(); wrapper = createComponent(mount);
return waitForPromises(); return waitForPromises();
}); });
it('does not render the violations table', () => { it('renders the empty table message', () => {
expect(findViolationsTable().exists()).toBe(false); expect(findViolationsTable().text()).toContain('No violations found');
});
it('renders the empty state', () => {
expect(findEmptyState().exists()).toBe(true);
expect(findEmptyState().props('imagePath')).toBe(emptyStateSvgPath);
}); });
}); });
......
import * as utils from 'ee/compliance_dashboard/utils'; import * as utils from 'ee/compliance_dashboard/utils';
describe('compliance report utils', () => { describe('compliance report utils', () => {
describe('parseViolationsQuery', () => { describe('convertProjectIdsToGraphQl', () => {
it('returns the expected result', () => { it('returns the expected result', () => {
const query = { expect(utils.convertProjectIdsToGraphQl(['1', '2'])).toStrictEqual([
projectIds: ['1', '2'], 'gid://gitlab/Project/1',
createdAfter: '2021-12-06', 'gid://gitlab/Project/2',
createdBefore: '2022-01-06', ]);
};
expect(utils.parseViolationsQuery(query)).toStrictEqual({
projectIds: ['gid://gitlab/Project/1', 'gid://gitlab/Project/2'],
createdAfter: query.createdAfter,
createdBefore: query.createdBefore,
});
}); });
}); });
}); });
...@@ -3,6 +3,7 @@ import Vue from 'vue'; ...@@ -3,6 +3,7 @@ import Vue from 'vue';
import { GlDaterangePicker } from '@gitlab/ui'; import { GlDaterangePicker } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ViolationFilter from 'ee/compliance_dashboard/components/violations/filter.vue'; import ViolationFilter from 'ee/compliance_dashboard/components/violations/filter.vue';
import { convertProjectIdsToGraphQl } from 'ee/compliance_dashboard/utils';
import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue'; import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue';
import { getDateInPast, pikadayToString } from '~/lib/utils/datetime_utility'; import { getDateInPast, pikadayToString } from '~/lib/utils/datetime_utility';
import { CURRENT_DATE } from 'ee/audit_events/constants'; import { CURRENT_DATE } from 'ee/audit_events/constants';
...@@ -117,21 +118,31 @@ describe('ViolationFilter component', () => { ...@@ -117,21 +118,31 @@ describe('ViolationFilter component', () => {
expect(wrapper.emitted('filters-changed')[0]).toStrictEqual([{ ...dateRangeQuery }]); expect(wrapper.emitted('filters-changed')[0]).toStrictEqual([{ ...dateRangeQuery }]);
}); });
it('emits the existing filter query with mutations on each update', async () => { describe('with a default query', () => {
await findProjectsFilter().vm.$emit('selected', []); const defaultQuery = { projectIds, createdAfter: '2022-01-01', createdBefore: '2022-01-31' };
expect(wrapper.emitted('filters-changed')).toHaveLength(1); beforeEach(() => {
expect(wrapper.emitted('filters-changed')[0]).toStrictEqual([{ projectIds: [] }]); createComponent({ defaultQuery });
});
await findDatePicker().vm.$emit('input', { startDate, endDate }); it('emits the existing filter query with mutations on each update', async () => {
await findProjectsFilter().vm.$emit('selected', []);
expect(wrapper.emitted('filters-changed')).toHaveLength(1);
expect(wrapper.emitted('filters-changed')[0]).toStrictEqual([
{ ...defaultQuery, projectIds: [] },
]);
expect(wrapper.emitted('filters-changed')).toHaveLength(2); await findDatePicker().vm.$emit('input', { startDate, endDate });
expect(wrapper.emitted('filters-changed')[1]).toStrictEqual([
{ expect(wrapper.emitted('filters-changed')).toHaveLength(2);
projectIds: [], expect(wrapper.emitted('filters-changed')[1]).toStrictEqual([
...dateRangeQuery, {
}, projectIds: [],
]); ...dateRangeQuery,
},
]);
});
}); });
}); });
...@@ -139,7 +150,10 @@ describe('ViolationFilter component', () => { ...@@ -139,7 +150,10 @@ describe('ViolationFilter component', () => {
it('fetches the project details when the default query contains projectIds', () => { it('fetches the project details when the default query contains projectIds', () => {
createComponent({ defaultQuery: { projectIds } }); createComponent({ defaultQuery: { projectIds } });
expect(groupProjectsSuccess).toHaveBeenCalledWith({ groupPath, projectIds }); expect(groupProjectsSuccess).toHaveBeenCalledWith({
groupPath,
projectIds: convertProjectIdsToGraphQl(projectIds),
});
}); });
describe('when the defaultProjects are being fetched', () => { describe('when the defaultProjects are being fetched', () => {
......
...@@ -8959,6 +8959,9 @@ msgstr "" ...@@ -8959,6 +8959,9 @@ msgstr ""
msgid "ComplianceReport|Less than 2 approvers" msgid "ComplianceReport|Less than 2 approvers"
msgstr "" msgstr ""
msgid "ComplianceReport|No violations found"
msgstr ""
msgid "Component" msgid "Component"
msgstr "" msgstr ""
...@@ -30449,7 +30452,7 @@ msgstr "" ...@@ -30449,7 +30452,7 @@ msgstr ""
msgid "Resync" msgid "Resync"
msgstr "" msgstr ""
msgid "Retrieving the compliance report failed. Please refresh the page and try again." msgid "Retrieving the compliance report failed. Refresh the page and try again."
msgstr "" msgstr ""
msgid "Retry" msgid "Retry"
......
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