diff --git a/ee/app/assets/javascripts/compliance_violations/components/compliance_violation_details_app.vue b/ee/app/assets/javascripts/compliance_violations/components/compliance_violation_details_app.vue
index 0eb136803bf767660eb90c250f61b0cad52834fc..9af1abf5ee1b0abc8468aa7806e7cf43a8cf343f 100644
--- a/ee/app/assets/javascripts/compliance_violations/components/compliance_violation_details_app.vue
+++ b/ee/app/assets/javascripts/compliance_violations/components/compliance_violation_details_app.vue
@@ -9,6 +9,7 @@ import updateProjectComplianceViolation from '../graphql/mutations/update_projec
import complianceViolationQuery from '../graphql/compliance_violation.query.graphql';
import AuditEvent from './audit_event.vue';
import FixSuggestionSection from './fix_suggestion_section.vue';
+import RelatedIssues from './related_issues.vue';
import ViolationSection from './violation_section.vue';
Vue.use(GlToast);
@@ -21,6 +22,7 @@ export default {
FixSuggestionSection,
GlAlert,
GlLoadingIcon,
+ RelatedIssues,
ViolationSection,
SystemNote,
},
@@ -178,6 +180,7 @@ export default {
:control-id="projectComplianceViolation.complianceControl.name"
:project-path="projectComplianceViolation.project.webUrl"
/>
+
{{ $options.i18n.activity }}
diff --git a/ee/app/assets/javascripts/compliance_violations/components/related_issues.vue b/ee/app/assets/javascripts/compliance_violations/components/related_issues.vue
new file mode 100644
index 0000000000000000000000000000000000000000..f02fe471d4ecb5eeaf7b8c871ebaa3ed1daffe67
--- /dev/null
+++ b/ee/app/assets/javascripts/compliance_violations/components/related_issues.vue
@@ -0,0 +1,94 @@
+
+
+
+
+ {{ s__('ComplianceViolation|Related issues') }}
+
+
+
+
+ {{ issuesCount }}
+
+
+
+
+ {{ __('Add') }}
+
+
+
+
+
+
+
+
+ {{ s__('ComplianceViolation|No related issues found.') }}
+
+
+
diff --git a/ee/app/assets/javascripts/compliance_violations/graphql/compliance_violation.query.graphql b/ee/app/assets/javascripts/compliance_violations/graphql/compliance_violation.query.graphql
index db3fde1cb6cad76b1735d938dd8f4de87614faa7..76d8ba33b54c9634b176883213087ef3f9ca09de 100644
--- a/ee/app/assets/javascripts/compliance_violations/graphql/compliance_violation.query.graphql
+++ b/ee/app/assets/javascripts/compliance_violations/graphql/compliance_violation.query.graphql
@@ -20,6 +20,15 @@ query getComplianceViolation($id: ComplianceManagementProjectsComplianceViolatio
}
}
}
+ issues {
+ nodes {
+ id
+ iid
+ name
+ state
+ webUrl
+ }
+ }
project {
id
nameWithNamespace
diff --git a/ee/spec/frontend/compliance_violations/components/compliance_violation_details_app_spec.js b/ee/spec/frontend/compliance_violations/components/compliance_violation_details_app_spec.js
index a06d90ce967969b45f4a0acb52fa06ea58acda0d..8374c78f89783701646455d51a0b608476917032 100644
--- a/ee/spec/frontend/compliance_violations/components/compliance_violation_details_app_spec.js
+++ b/ee/spec/frontend/compliance_violations/components/compliance_violation_details_app_spec.js
@@ -10,6 +10,7 @@ import AuditEvent from 'ee/compliance_violations/components/audit_event.vue';
import ViolationSection from 'ee/compliance_violations/components/violation_section.vue';
import SystemNote from '~/work_items/components/notes/system_note.vue';
import FixSuggestionSection from 'ee/compliance_violations/components/fix_suggestion_section.vue';
+import RelatedIssues from 'ee/compliance_violations/components/related_issues.vue';
import { ComplianceViolationStatusDropdown } from 'ee/vue_shared/compliance';
import complianceViolationQuery from 'ee/compliance_violations/graphql/compliance_violation.query.graphql';
import updateProjectComplianceViolation from 'ee/compliance_violations/graphql/mutations/update_project_compliance_violation.mutation.graphql';
@@ -49,6 +50,17 @@ describe('ComplianceViolationDetailsApp', () => {
},
},
},
+ issues: {
+ nodes: [
+ {
+ id: '1',
+ iid: '1',
+ name: 'Test',
+ state: 'opened',
+ webUrl: 'https://localhost:3000/gitlab/org/gitlab-test/-/issues/1',
+ },
+ ],
+ },
project: {
id: 'gid://gitlab/Project/2',
nameWithNamespace: 'GitLab.org / GitLab Test',
@@ -233,6 +245,7 @@ describe('ComplianceViolationDetailsApp', () => {
const findAuditEvent = () => wrapper.findComponent(AuditEvent);
const findViolationSection = () => wrapper.findComponent(ViolationSection);
const findFixSuggestionSection = () => wrapper.findComponent(FixSuggestionSection);
+ const findRelatedIssues = () => wrapper.findComponent(RelatedIssues);
const findErrorMessage = () => wrapper.findComponent(GlAlert);
const findSystemNotes = () => wrapper.findAllComponents(SystemNote);
const findActivitySection = () => wrapper.find('.issuable-discussion');
@@ -335,6 +348,12 @@ describe('ComplianceViolationDetailsApp', () => {
);
});
+ it('renders the related issues section', () => {
+ const relatedIssuesComponent = findRelatedIssues();
+ expect(relatedIssuesComponent.exists()).toBe(true);
+ expect(relatedIssuesComponent.props('issues')).toEqual(mockComplianceViolation.issues.nodes);
+ });
+
describe('when violation has an audit event', () => {
it('renders the audit event component with correct props', () => {
const auditEventComponent = findAuditEvent();
diff --git a/ee/spec/frontend/compliance_violations/components/related_issues_spec.js b/ee/spec/frontend/compliance_violations/components/related_issues_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..a77da172bb755d9af1f941f2aa3993a3be93e3c8
--- /dev/null
+++ b/ee/spec/frontend/compliance_violations/components/related_issues_spec.js
@@ -0,0 +1,228 @@
+import { GlIcon } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import CrudComponent from '~/vue_shared/components/crud_component.vue';
+import RelatedIssues from 'ee/compliance_violations/components/related_issues.vue';
+
+describe('RelatedIssues', () => {
+ let wrapper;
+
+ const mockIssues = [
+ {
+ id: '1',
+ name: 'Fix critical bug in authentication',
+ webUrl: 'https://gitlab.example.com/project/-/issues/1',
+ iid: '123',
+ state: 'opened',
+ },
+ {
+ id: '2',
+ name: 'Update documentation for API changes',
+ webUrl: 'https://gitlab.example.com/project/-/issues/2',
+ iid: '456',
+ state: 'closed',
+ },
+ ];
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMountExtended(RelatedIssues, {
+ propsData: {
+ issues: mockIssues,
+ ...props,
+ },
+ });
+ };
+
+ const findCrudComponent = () => wrapper.findComponent(CrudComponent);
+ const findAddButton = () => wrapper.findByTestId('add-related-issue-button');
+ const findAllIssueItems = () => wrapper.findAllByTestId('issue-item');
+ const findAllIssueLinks = () => wrapper.findAllByTestId('issue-link');
+ const findAllRemoveButtons = () => wrapper.findAllByTestId('remove-issue-button');
+ const findAllIssueStateIcons = () => wrapper.findAllByTestId('issue-state-icon');
+
+ const findIssueLinkByIndex = (index) => findAllIssueLinks().at(index);
+ const findRemoveButtonByIndex = (index) => findAllRemoveButtons().at(index);
+ const findIssueStateIconByIndex = (index) => findAllIssueStateIcons().at(index);
+
+ afterEach(() => {
+ wrapper?.destroy();
+ });
+
+ describe('when rendered with issues', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders the CrudComponent with correct props', () => {
+ const crudComponent = findCrudComponent();
+ expect(crudComponent.exists()).toBe(true);
+ expect(crudComponent.props('isCollapsible')).toBe(true);
+ });
+
+ it('displays the correct title', () => {
+ expect(wrapper.text()).toContain('Related issues');
+ });
+
+ it('displays the correct issue count', () => {
+ const countIcon = wrapper.findComponent(GlIcon);
+ expect(countIcon.props('name')).toBe('issues');
+ expect(wrapper.text()).toContain('2');
+ });
+
+ it('renders the add button', () => {
+ const addButton = findAddButton();
+ expect(addButton.exists()).toBe(true);
+ expect(addButton.text()).toBe('Add');
+ expect(addButton.props('size')).toBe('small');
+ });
+
+ it('renders all issues with correct information', () => {
+ const allIssueItems = findAllIssueItems();
+ const allIssueLinks = findAllIssueLinks();
+
+ expect(allIssueItems).toHaveLength(2);
+ expect(allIssueLinks).toHaveLength(2);
+
+ const firstIssueLink = findIssueLinkByIndex(0);
+ expect(firstIssueLink.text()).toBe('Fix critical bug in authentication');
+ expect(firstIssueLink.attributes('href')).toBe(
+ 'https://gitlab.example.com/project/-/issues/1',
+ );
+
+ const secondIssueLink = findIssueLinkByIndex(1);
+ expect(secondIssueLink.text()).toBe('Update documentation for API changes');
+ expect(secondIssueLink.attributes('href')).toBe(
+ 'https://gitlab.example.com/project/-/issues/2',
+ );
+
+ expect(wrapper.text()).toContain('#123');
+ expect(wrapper.text()).toContain('#456');
+ });
+
+ it('renders correct state icons for issues', () => {
+ const allIssueStateIcons = findAllIssueStateIcons();
+ expect(allIssueStateIcons).toHaveLength(2);
+
+ const firstIssueIcon = findIssueStateIconByIndex(0);
+ expect(firstIssueIcon.props('name')).toBe('issue-open-m');
+ expect(firstIssueIcon.props('variant')).toBe('success');
+
+ const secondIssueIcon = findIssueStateIconByIndex(1);
+ expect(secondIssueIcon.props('name')).toBe('issue-close');
+ expect(secondIssueIcon.props('variant')).toBe('info');
+ });
+
+ it('renders remove buttons for each issue', () => {
+ const allRemoveButtons = findAllRemoveButtons();
+ expect(allRemoveButtons).toHaveLength(2);
+
+ const firstRemoveButton = findRemoveButtonByIndex(0);
+ expect(firstRemoveButton.props('category')).toBe('tertiary');
+ expect(firstRemoveButton.props('icon')).toBe('close');
+ expect(firstRemoveButton.props('size')).toBe('small');
+
+ const secondRemoveButton = findRemoveButtonByIndex(1);
+ expect(secondRemoveButton.props('category')).toBe('tertiary');
+ expect(secondRemoveButton.props('icon')).toBe('close');
+ expect(secondRemoveButton.props('size')).toBe('small');
+ });
+
+ it('does not show empty state', () => {
+ expect(wrapper.text()).not.toContain('No related issues found.');
+ });
+ });
+
+ describe('when rendered with empty issues array', () => {
+ beforeEach(() => {
+ createComponent({ issues: [] });
+ });
+
+ it('displays zero count', () => {
+ expect(wrapper.text()).toContain('0');
+ });
+
+ it('shows empty state message', () => {
+ expect(wrapper.text()).toContain('No related issues found.');
+ });
+
+ it('does not render any issue items', () => {
+ const allIssueItems = findAllIssueItems();
+ expect(allIssueItems).toHaveLength(0);
+ });
+
+ it('does not render any remove buttons', () => {
+ const allRemoveButtons = findAllRemoveButtons();
+ expect(allRemoveButtons).toHaveLength(0);
+ });
+
+ it('still renders the add button', () => {
+ const addButton = findAddButton();
+ expect(addButton.exists()).toBe(true);
+ });
+ });
+
+ describe('when rendered with single issue', () => {
+ beforeEach(() => {
+ createComponent({
+ issues: [mockIssues[0]],
+ });
+ });
+
+ it('displays correct count', () => {
+ expect(wrapper.text()).toContain('1');
+ });
+
+ it('renders single issue correctly', () => {
+ const allIssueItems = findAllIssueItems();
+ const allIssueLinks = findAllIssueLinks();
+
+ expect(allIssueItems).toHaveLength(1);
+ expect(allIssueLinks).toHaveLength(1);
+
+ const issueLink = findIssueLinkByIndex(0);
+ expect(issueLink.text()).toBe('Fix critical bug in authentication');
+ });
+
+ it('renders single remove button', () => {
+ const allRemoveButtons = findAllRemoveButtons();
+ expect(allRemoveButtons).toHaveLength(1);
+ });
+ });
+
+ describe('issue state variations', () => {
+ it('handles opened state correctly', () => {
+ createComponent({
+ issues: [
+ {
+ id: '1',
+ name: 'Test issue',
+ webUrl: 'https://example.com',
+ iid: '1',
+ state: 'opened',
+ },
+ ],
+ });
+
+ const issueIcon = findIssueStateIconByIndex(0);
+ expect(issueIcon.props('name')).toBe('issue-open-m');
+ expect(issueIcon.props('variant')).toBe('success');
+ });
+
+ it('handles closed state correctly', () => {
+ createComponent({
+ issues: [
+ {
+ id: '1',
+ name: 'Test issue',
+ webUrl: 'https://example.com',
+ iid: '1',
+ state: 'closed',
+ },
+ ],
+ });
+
+ const issueIcon = findIssueStateIconByIndex(0);
+ expect(issueIcon.props('name')).toBe('issue-close');
+ expect(issueIcon.props('variant')).toBe('info');
+ });
+ });
+});
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 2772eaf90931a37ff6ebbb53202e6638858bb7b1..9f83bf72c431a4553f9fdfcf69bf6a81c3adf417 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -17277,9 +17277,15 @@ msgstr ""
msgid "ComplianceViolation|In review"
msgstr ""
+msgid "ComplianceViolation|No related issues found."
+msgstr ""
+
msgid "ComplianceViolation|Registered event IP"
msgstr ""
+msgid "ComplianceViolation|Related issues"
+msgstr ""
+
msgid "ComplianceViolation|Requirement"
msgstr ""