From 947943b3e95a56211a2740925c78a72db0f8b80e Mon Sep 17 00:00:00 2001 From: Scott Hampton Date: Fri, 18 Jul 2025 13:19:42 -0700 Subject: [PATCH] Show list of related issues This is the first iteration for adding related issues to compliance violations. This iteration only shows the list of the related issues. The actions remove/add don't have any functionality yet. --- .../compliance_violation_details_app.vue | 3 + .../components/related_issues.vue | 94 ++++++++ .../compliance_violation.query.graphql | 9 + .../compliance_violation_details_app_spec.js | 19 ++ .../components/related_issues_spec.js | 228 ++++++++++++++++++ locale/gitlab.pot | 6 + 6 files changed, 359 insertions(+) create mode 100644 ee/app/assets/javascripts/compliance_violations/components/related_issues.vue create mode 100644 ee/spec/frontend/compliance_violations/components/related_issues_spec.js 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 0eb136803bf767..9af1abf5ee1b0a 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 00000000000000..f02fe471d4ecb5 --- /dev/null +++ b/ee/app/assets/javascripts/compliance_violations/components/related_issues.vue @@ -0,0 +1,94 @@ + + 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 db3fde1cb6cad7..76d8ba33b54c96 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 a06d90ce967969..8374c78f897837 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 00000000000000..a77da172bb755d --- /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 2772eaf90931a3..9f83bf72c431a4 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 "" -- GitLab