diff --git a/components/api_server/src/routes/metric.py b/components/api_server/src/routes/metric.py index 443068c145..54b98da1e0 100644 --- a/components/api_server/src/routes/metric.py +++ b/components/api_server/src/routes/metric.py @@ -187,18 +187,26 @@ def add_metric_issue(metric_uuid: MetricId, database: Database): metric, subject = report.instance_and_parents_for_uuid(metric_uuid=metric_uuid) last_measurement = latest_successful_measurement(database, metric) measured_value = last_measurement.value() if last_measurement else "missing" + entity_key = dict(bottle.request.json).get("entity_key") issue_tracker = report.issue_tracker() issue_key, error = issue_tracker.create_issue(*create_issue_text(metric, measured_value)) if error: return {"ok": False, "error": error} else: # pragma: no feature-test-cover # noqa: RET505 - old_issue_ids = metric.get("issue_ids") or [] - new_issue_ids = sorted([issue_key, *old_issue_ids]) + new_issue_ids: list[str] | dict[str, list[str]] + if entity_key: + old_issue_ids = metric.get("entity_issue_ids", {}) + new_issue_ids = {**old_issue_ids} + new_issue_ids.setdefault(entity_key, []).append(issue_key) + report["subjects"][subject.uuid]["metrics"][metric_uuid]["entity_issue_ids"] = new_issue_ids + else: + old_issue_ids = metric.get("issue_ids") or [] + new_issue_ids = sorted([issue_key, *old_issue_ids]) + report["subjects"][subject.uuid]["metrics"][metric_uuid]["issue_ids"] = new_issue_ids description = ( - f"{{user}} changed the issue_ids of metric '{metric.name}' of subject " + f"{{user}} changed the {'entity_' if entity_key else ''}issue_ids of metric '{metric.name}' of subject " f"'{subject.name}' in report '{report.name}' from '{old_issue_ids}' to '{new_issue_ids}'." ) - report["subjects"][subject.uuid]["metrics"][metric_uuid]["issue_ids"] = new_issue_ids insert_new_report(database, description, [report.uuid, subject.uuid, metric.uuid], report) return {"ok": True, "issue_url": issue_tracker.browse_url(issue_key)} diff --git a/components/api_server/tests/routes/test_metric.py b/components/api_server/tests/routes/test_metric.py index a0bbc3a096..332bc831a1 100644 --- a/components/api_server/tests/routes/test_metric.py +++ b/components/api_server/tests/routes/test_metric.py @@ -651,6 +651,12 @@ def assert_issue_inserted(self): inserted_issue_ids = inserted_report["subjects"][SUBJECT_ID]["metrics"][METRIC_ID]["issue_ids"] self.assertEqual(["FOO-42"], inserted_issue_ids) + def assert_entity_issue_inserted(self): + """Check that the entity issue is inserted in the database.""" + inserted_report = self.database.reports.insert_one.call_args_list[0][0][0] + inserted_entity_issue_ids = inserted_report["subjects"][SUBJECT_ID]["metrics"][METRIC_ID]["entity_issue_ids"] + self.assertEqual({"key": ["FOO-42"]}, inserted_entity_issue_ids) + @patch("bottle.request", Mock(json={"metric_url": METRIC_URL})) def test_add_metric_issue(self, requests_post): """Test that an issue can be added to the issue tracker.""" @@ -661,6 +667,16 @@ def test_add_metric_issue(self, requests_post): self.assert_issue_posted(requests_post) self.assert_issue_inserted() + @patch("bottle.request", Mock(json={"entity_key": "key", "metric_url": METRIC_URL})) + def test_add_metric_entity_issue(self, requests_post): + """Test that an issue can be added to the issue tracker and linked to a measurement entity.""" + response = Mock() + response.json.return_value = {"key": "FOO-42"} + requests_post.return_value = response + self.assertEqual({"ok": True, "issue_url": self.ISSUE_URL}, add_metric_issue(METRIC_ID, self.database)) + self.assert_issue_posted(requests_post) + self.assert_entity_issue_inserted() + @patch("bottle.request", Mock(json={"metric_url": METRIC_URL})) @patch("model.issue_tracker.requests.get") def test_add_metric_issue_with_labels(self, requests_get, requests_post): diff --git a/components/collector/src/base_collectors/metric_collector.py b/components/collector/src/base_collectors/metric_collector.py index c492caee10..6f96e74b01 100644 --- a/components/collector/src/base_collectors/metric_collector.py +++ b/components/collector/src/base_collectors/metric_collector.py @@ -70,10 +70,10 @@ def __issue_status_collectors(self) -> list[Coroutine]: tracker_type = tracker.get("type") has_tracker = bool(tracker_type and tracker.get("parameters", {}).get("url")) if has_tracker and (collector_class := SourceCollector.get_subclass(tracker_type, "issue_status")): - return [ - collector_class(self.__session, tracker).collect_issue_status(issue_id) - for issue_id in self._metric.get("issue_ids", []) - ] + issue_ids = self._metric.get("issue_ids", []) + for entity_issue_id_list in self._metric.get("entity_issue_ids", {}).values(): + issue_ids.extend(entity_issue_id_list) + return [collector_class(self.__session, tracker).collect_issue_status(issue_id) for issue_id in issue_ids] return [] def __has_all_mandatory_parameters(self, source) -> bool: diff --git a/components/collector/tests/source_collectors/jira/test_issue_status.py b/components/collector/tests/source_collectors/jira/test_issue_status.py index dbad29d323..e2aa99474d 100644 --- a/components/collector/tests/source_collectors/jira/test_issue_status.py +++ b/components/collector/tests/source_collectors/jira/test_issue_status.py @@ -84,6 +84,16 @@ async def test_issue_status(self): response = await self.collect(get_request_json_return_value=issue_status_json) self.assert_issue_status(response) + async def test_entity_issue_status(self): + """Test that the issue status is returned for a measurement entity.""" + self.metric["issue_ids"] = [] + self.metric["entity_issue_ids"] = {"entity key": ["FOO-42"]} + issue_status_json = { + "fields": {"status": {"name": self.ISSUE_NAME, "statusCategory": {"key": "new"}}, "created": self.CREATED}, + } + response = await self.collect(get_request_json_return_value=issue_status_json) + self.assert_issue_status(response) + async def test_issue_status_doing(self): """Test that the issue status is returned.""" issue_status_json = { diff --git a/components/frontend/src/api/metric.js b/components/frontend/src/api/metric.js index 33234ca4d6..57030c1508 100644 --- a/components/frontend/src/api/metric.js +++ b/components/frontend/src/api/metric.js @@ -25,8 +25,9 @@ export function set_metric_debt(metric_uuid, value, reload) { fetch_server_api('post', `metric/${metric_uuid}/debt`, { "accept_debt": value }).then(reload) } -export function add_metric_issue(metric_uuid, reload) { - return fetch_server_api('post', `metric/${metric_uuid}/issue/new`, {metric_url: `${window.location}#${metric_uuid}`}).then((json) => { +export function add_metric_issue(entityKey, metric_uuid, reload) { + const payload = {metric_url: `${window.location}#${metric_uuid}`, entity_key: entityKey} + return fetch_server_api('post', `metric/${metric_uuid}/issue/new`, payload).then((json) => { if (json.ok) { window.open(json.issue_url) } else { diff --git a/components/frontend/src/issue/IssueStatus.js b/components/frontend/src/issue/IssueStatus.js index 369a507e68..9a6099e13f 100644 --- a/components/frontend/src/issue/IssueStatus.js +++ b/components/frontend/src/issue/IssueStatus.js @@ -157,29 +157,31 @@ issuePopupContent.propTypes = { issueStatus: issueStatusPropType } -function IssuesWithTracker({ metric, settings }) { +function IssuesWithTracker({ issueIds, metric, settings }) { const issueStatuses = metric.issue_status || []; return <> { - issueStatuses.map((issueStatus) => + issueStatuses.filter((issueStatus) => issueIds.indexOf(issueStatus.issue_id) > -1).map((issueStatus) => ) } } IssuesWithTracker.propTypes = { + issueIds: stringsPropType, metric: metricPropType, settings: settingsPropType } -export function IssueStatus({ metric, issueTrackerMissing, settings }) { - const issueIds = getMetricIssueIds(metric) +export function IssueStatus({ entityKey, metric, issueTrackerMissing, settings }) { + const issueIds = getMetricIssueIds(metric, entityKey) if (issueTrackerMissing && issueIds.length > 0) { return } - return + return } IssueStatus.propTypes = { + entityKey: PropTypes.string, issueTrackerMissing: PropTypes.bool, metric: metricPropType, settings: settingsPropType diff --git a/components/frontend/src/issue/IssuesRows.js b/components/frontend/src/issue/IssuesRows.js new file mode 100644 index 0000000000..4be8905f28 --- /dev/null +++ b/components/frontend/src/issue/IssuesRows.js @@ -0,0 +1,162 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { Grid } from 'semantic-ui-react'; +import { set_metric_attribute, add_metric_issue } from '../api/metric'; +import { get_report_issue_tracker_suggestions } from '../api/report'; +import { EDIT_REPORT_PERMISSION, ReadOnlyOrEditable } from '../context/Permissions'; +import { MultipleChoiceInput } from '../fields/MultipleChoiceInput'; +import { ActionButton } from '../widgets/Button'; +import { LabelWithHelp } from '../widgets/LabelWithHelp'; +import { ErrorMessage } from '../errorMessage'; +import { getMetricIssueIds } from '../utils'; +import { metricPropType, reportPropType } from '../sharedPropTypes'; + +function CreateIssueButton({ entityKey, issueTrackerConfigured, issueTrackerInstruction, metric_uuid, target, reload }) { + return ( + add_metric_issue(entityKey, metric_uuid, reload)} + popup={<>Create a new issue for this {target} in the configured issue tracker and add its identifier to the tracked issue identifiers.{issueTrackerInstruction}} + position='top center' + /> + ) +} +CreateIssueButton.propTypes = { + entityKey: PropTypes.string, + issueTrackerConfigured: PropTypes.bool, + issueTrackerInstruction: PropTypes.node, + metric_uuid: PropTypes.string, + target: PropTypes.string, + reload: PropTypes.func +} + +function IssueIdentifiers({ entityKey, issueTrackerInstruction, metric, metric_uuid, report_uuid, target, reload }) { + const issueStatusHelp = ( + <> +

Identifiers of issues in the configured issue tracker that track the progress of fixing this {target}.

+

When the issues have all been resolved, or the technical debt end date has passed, whichever happens first, the technical debt should be resolved and the technical debt target is no longer evaluated.

+ {issueTrackerInstruction} + + ) + const [suggestions, setSuggestions] = useState([]); + const labelId = `issue-identifiers-label-${metric_uuid}` + const issue_ids = getMetricIssueIds(metric, entityKey); + return ( + { + if (query) { + get_report_issue_tracker_suggestions(report_uuid, query).then((suggestionsResponse) => { + const suggestionOptions = suggestionsResponse.suggestions.map((s) => ({ key: s.key, text: `${s.key}: ${s.text}`, value: s.key })) + setSuggestions(suggestionOptions) + }) + } else { + setSuggestions([]) + } + }} + requiredPermissions={[EDIT_REPORT_PERMISSION]} + label={} + options={suggestions} + set_value={(value) => set_metric_attribute(metric_uuid, "issue_ids", value, reload)} + value={issue_ids} + key={issue_ids} // Make sure the multiple choice input is rerendered when the issue ids change + /> + ) +} +IssueIdentifiers.propTypes = { + entityKey: PropTypes.string, + issueTrackerInstruction: PropTypes.node, + metric: metricPropType, + metric_uuid: PropTypes.string, + report_uuid: PropTypes.string, + target: PropTypes.string, + reload: PropTypes.func +} + +export function IssuesRows({ entityKey, metric, metric_uuid, reload, report, target }) { + const parameters = report?.issue_tracker?.parameters; + const issueTrackerConfigured = Boolean(report?.issue_tracker?.type && parameters?.url && parameters?.project_key && parameters?.issue_type); + const issueTrackerInstruction = issueTrackerConfigured ? null :

Please configure an issue tracker by expanding the report title, selecting the 'Issue tracker' tab, and configuring an issue tracker.

; + const issueIdentifiersProps = { + entityKey: entityKey, + issueTrackerInstruction: issueTrackerInstruction, + metric: metric, + metric_uuid: metric_uuid, + report_uuid: report.report_uuid, + target: target ?? "metric", + reload: reload + } + return ( + <> + + + + + } + editableComponent={ + <> + + + + + + + + } + /> + + {(getMetricIssueIds(metric, entityKey).length > 0 && !issueTrackerConfigured) && + + + + + + } + {(metric.issue_status ?? []).filter((issue_status => issue_status.connection_error)).map((issue_status) => + + + + + + )} + {(metric.issue_status ?? []).filter((issue_status => issue_status.parse_error)).map((issue_status) => + + + + + + )} + + ) +} +IssuesRows.propTypes = { + entityKey: PropTypes.string, + metric: metricPropType, + metric_uuid: PropTypes.string, + reload: PropTypes.func, + report: reportPropType, + target: PropTypes.string, +} diff --git a/components/frontend/src/issue/IssuesRows.test.js b/components/frontend/src/issue/IssuesRows.test.js new file mode 100644 index 0000000000..f527ebc357 --- /dev/null +++ b/components/frontend/src/issue/IssuesRows.test.js @@ -0,0 +1,112 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { EDIT_REPORT_PERMISSION, Permissions } from '../context/Permissions'; +import { IssuesRows } from './IssuesRows'; +import * as fetch_server_api from '../api/fetch_server_api'; + +jest.mock("../api/fetch_server_api.js") + +const reportWithIssueTracker = { + issue_tracker: { type: "Jira", parameters: { url: "https://jira", project_key: "KEY", issue_type: "Bug" } } +} + +function renderIssuesRow( + { + issue_ids = [], + report = { subjects: {} }, + permissions = [EDIT_REPORT_PERMISSION], + issue_status = [] + } = {}) { + render( + + + + ); +} + +it('does not show an error message if the metric has no issues and no issue tracker is configured', () => { + renderIssuesRow() + expect(screen.queryAllByText(/No issue tracker configured/).length).toBe(0); +}); + +it('does not show an error message if the metric has no issues and an issue tracker is configured', () => { + renderIssuesRow({ report: { issue_tracker: { type: "Jira" } } }) + expect(screen.queryAllByText(/No issue tracker configured/).length).toBe(0); +}); + +it('does not show an error message if the metric has issues and an issue tracker is configured', () => { + renderIssuesRow({ issue_ids: ["BAR-42"], report: { issue_tracker: { type: "Jira", parameters: { url: "https://jira", project_key: "KEY", issue_type: "Bug" } } } }) + expect(screen.queryAllByText(/No issue tracker configured/).length).toBe(0); +}); + +it('shows an error message if the metric has issues but no issue tracker is configured', () => { + renderIssuesRow({ issue_ids: ["FOO-42"] }) + expect(screen.queryAllByText(/No issue tracker configured/).length).toBe(1); +}); + +it('shows a connection error', () => { + renderIssuesRow({ issue_status: [{ issue_id: "FOO-43", connection_error: "Oops" }] }) + expect(screen.queryAllByText(/Connection error/).length).toBe(1); + expect(screen.queryAllByText(/Oops/).length).toBe(1); +}); + +it('shows a parse error', () => { + renderIssuesRow({ issue_status: [{ issue_id: "FOO-43", parse_error: "Oops" }] }) + expect(screen.queryAllByText(/Parse error/).length).toBe(1); + expect(screen.queryAllByText(/Oops/).length).toBe(1); +}); + +it('creates an issue', () => { + window.open = jest.fn() + fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: true, error: "", issue_url: "https://tracker/foo-42" }); + renderIssuesRow({ report: reportWithIssueTracker }) + fireEvent.click(screen.getByText(/Create new issue/)) + expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith("post", "metric/metric_uuid/issue/new", { metric_url: "http://localhost/#metric_uuid" }); +}); + +it('tries to create an issue', () => { + fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: false, error: "Dummy", issue_url: "" }); + renderIssuesRow({ report: reportWithIssueTracker }) + fireEvent.click(screen.getByText(/Create new issue/)) + expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith("post", "metric/metric_uuid/issue/new", { metric_url: "http://localhost/#metric_uuid" }); +}); + +it('does not show the create issue button if the user has no permissions', () => { + renderIssuesRow({ report: reportWithIssueTracker, permissions: [] }) + expect(screen.queryAllByText(/Create new issue/).length).toBe(0) +}) + +it('adds an issue id', async () => { + fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ suggestions: [{ key: "FOO-42", text: "Suggestion" }] }); + renderIssuesRow() + await userEvent.type(screen.getByLabelText(/Issue identifiers/), 'FOO-42{Enter}'); + expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith("post", "metric/metric_uuid/attribute/issue_ids", { issue_ids: ["FOO-42"] }); +}); + +it('shows issue id suggestions', async () => { + fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ suggestions: [{ key: "FOO-42", text: "Suggestion" }] }); + renderIssuesRow({ report: { issue_tracker: { type: "Jira", parameters: { url: "https://jira" } } } }) + await userEvent.type(screen.getByLabelText(/Issue identifiers/), 'u'); + expect(screen.queryAllByText(/FOO-42: Suggestion/).length).toBe(1) +}); + +it('shows no issue id suggestions without a query', async () => { + fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ suggestions: [{ key: "FOO-42", text: "Suggestion" }] }); + renderIssuesRow({ report: { issue_tracker: { type: "Jira", parameters: { url: "https://jira" } } } }) + await userEvent.type(screen.getByLabelText(/Issue identifiers/), 's'); + expect(screen.queryAllByText(/FOO-42: Suggestion/).length).toBe(1) + await userEvent.clear(screen.getByLabelText(/Issue identifiers/).firstChild); + expect(screen.queryAllByText(/FOO-42: Suggestion/).length).toBe(0) +}); diff --git a/components/frontend/src/metric/MetricDebtParameters.js b/components/frontend/src/metric/MetricDebtParameters.js index 5379b6ab25..e64b2e9896 100644 --- a/components/frontend/src/metric/MetricDebtParameters.js +++ b/components/frontend/src/metric/MetricDebtParameters.js @@ -1,18 +1,13 @@ -import React, { useState } from 'react'; +import React from 'react'; import { Grid } from 'semantic-ui-react'; -import { get_report_issue_tracker_suggestions } from '../api/report'; -import { MultipleChoiceInput } from '../fields/MultipleChoiceInput'; import { SingleChoiceInput } from '../fields/SingleChoiceInput'; import { Comment } from '../fields/Comment'; -import { set_metric_attribute, set_metric_debt, add_metric_issue } from '../api/metric'; +import { set_metric_attribute, set_metric_debt } from '../api/metric'; import { DateInput } from '../fields/DateInput'; -import { ActionButton } from '../widgets/Button'; import { LabelWithDate } from '../widgets/LabelWithDate'; -import { LabelWithHelp } from '../widgets/LabelWithHelp'; import { LabelWithHyperLink } from '../widgets/LabelWithHyperLink'; -import { ErrorMessage } from '../errorMessage'; -import { EDIT_REPORT_PERMISSION, ReadOnlyOrEditable } from '../context/Permissions'; -import { getMetricIssueIds } from '../utils'; +import { EDIT_REPORT_PERMISSION } from '../context/Permissions'; +import { IssuesRows } from '../issue/IssuesRows'; import { Target } from './Target'; function AcceptTechnicalDebt({ metric, metric_uuid, reload }) { @@ -67,46 +62,7 @@ function TechnicalDebtEndDate({ metric, metric_uuid, reload }) { ) } -function IssueIdentifiers({ issue_tracker_instruction, metric, metric_uuid, report_uuid, reload }) { - const issueStatusHelp = ( - <> -

Identifiers of issues in the configured issue tracker that track the progress of fixing this metric.

-

When the issues have all been resolved, or the technical debt end date has passed, whichever happens first, the technical debt should be resolved and the technical debt target is no longer evaluated.

- {issue_tracker_instruction} - - ) - const [suggestions, setSuggestions] = useState([]); - const labelId = `issue-identifiers-label-${metric_uuid}` - const issue_ids = getMetricIssueIds(metric); - return ( - { - if (query) { - get_report_issue_tracker_suggestions(report_uuid, query).then((suggestionsResponse) => { - const suggestionOptions = suggestionsResponse.suggestions.map((s) => ({ key: s.key, text: `${s.key}: ${s.text}`, value: s.key })) - setSuggestions(suggestionOptions) - }) - } else { - setSuggestions([]) - } - }} - requiredPermissions={[EDIT_REPORT_PERMISSION]} - label={} - options={suggestions} - set_value={(value) => set_metric_attribute(metric_uuid, "issue_ids", value, reload)} - value={issue_ids} - key={issue_ids} // Make sure the multiple choice input is rerendered when the issue ids change - /> - ) -} - - export function MetricDebtParameters({ report, metric, metric_uuid, reload }) { - const parameters = report?.issue_tracker?.parameters; - const issueTrackerConfigured = report?.issue_tracker?.type && parameters?.url && parameters?.project_key && parameters?.issue_type; - const issueTrackerInstruction = issueTrackerConfigured ? null :

Please configure an issue tracker by expanding the report title, selecting the 'Issue tracker' tab, and configuring an issue tracker.

; return ( @@ -120,56 +76,7 @@ export function MetricDebtParameters({ report, metric, metric_uuid, reload }) { - - - - - } - editableComponent={ - <> - < Grid.Column width={3} verticalAlign="bottom"> - add_metric_issue(metric_uuid, reload)} - popup={

Create a new issue for this metric in the configured issue tracker and add its identifier to the tracked issue identifiers.{issueTrackerInstruction}

} - position='top center' - /> - - - - - - } - /> -
- {(getMetricIssueIds(metric).length > 0 && !issueTrackerConfigured) && - - - - - - } - {(metric.issue_status ?? []).filter((issue_status => issue_status.connection_error)).map((issue_status) => - - - - - - )} - {(metric.issue_status ?? []).filter((issue_status => issue_status.parse_error)).map((issue_status) => - - - - - - )} + @@ -43,8 +37,6 @@ function renderMetricDebtParameters( tags: [], accept_debt: accept_debt, scale: scale, - issue_ids: issue_ids, - issue_status: issue_status } } metric_uuid="metric_uuid" @@ -84,81 +76,6 @@ it('sets the technical debt end date', async () => { expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith("post", "metric/metric_uuid/attribute/debt_end_date", { debt_end_date: "2022-12-31" }); }); -it('does not show an error message if the metric has no issues and no issue tracker is configured', async () => { - renderMetricDebtParameters() - expect(screen.queryAllByText(/No issue tracker configured/).length).toBe(0); -}); - -it('does not show an error message if the metric has no issues and an issue tracker is configured', async () => { - renderMetricDebtParameters({ report: { issue_tracker: { type: "Jira" } } }) - expect(screen.queryAllByText(/No issue tracker configured/).length).toBe(0); -}); - -it('does not show an error message if the metric has issues and an issue tracker is configured', async () => { - renderMetricDebtParameters({ issue_ids: ["BAR-42"], report: { issue_tracker: { type: "Jira", parameters: { url: "https://jira", project_key: "KEY", issue_type: "Bug" } } } }) - expect(screen.queryAllByText(/No issue tracker configured/).length).toBe(0); -}); - -it('shows an error message if the metric has issues but no issue tracker is configured', async () => { - renderMetricDebtParameters({ issue_ids: ["FOO-42"] }) - expect(screen.queryAllByText(/No issue tracker configured/).length).toBe(1); -}); - -it('shows a connection error', async () => { - renderMetricDebtParameters({ issue_status: [{ issue_id: "FOO-43", connection_error: "Oops" }] }) - expect(screen.queryAllByText(/Connection error/).length).toBe(1); - expect(screen.queryAllByText(/Oops/).length).toBe(1); -}); - -it('shows a parse error', async () => { - renderMetricDebtParameters({ issue_status: [{ issue_id: "FOO-43", parse_error: "Oops" }] }) - expect(screen.queryAllByText(/Parse error/).length).toBe(1); - expect(screen.queryAllByText(/Oops/).length).toBe(1); -}); - -it('creates an issue', async () => { - window.open = jest.fn() - fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: true, error: "", issue_url: "https://tracker/foo-42" }); - renderMetricDebtParameters({ report: reportWithIssueTracker }) - fireEvent.click(screen.getByText(/Create new issue/)) - expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith("post", "metric/metric_uuid/issue/new", { metric_url: "http://localhost/#metric_uuid" }); -}); - -it('tries to create an issue', async () => { - fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: false, error: "Dummy", issue_url: "" }); - renderMetricDebtParameters({ report: reportWithIssueTracker }) - fireEvent.click(screen.getByText(/Create new issue/)) - expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith("post", "metric/metric_uuid/issue/new", { metric_url: "http://localhost/#metric_uuid" }); -}); - -it('does not show the create issue button if the user has no permissions', async () => { - renderMetricDebtParameters({ report: reportWithIssueTracker, permissions: [] }) - expect(screen.queryAllByText(/Create new issue/).length).toBe(0) -}) - -it('adds an issue id', async () => { - fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ suggestions: [{ key: "FOO-42", text: "Suggestion" }] }); - renderMetricDebtParameters() - await userEvent.type(screen.getByLabelText(/Issue identifiers/), 'FOO-42{Enter}'); - expect(fetch_server_api.fetch_server_api).toHaveBeenLastCalledWith("post", "metric/metric_uuid/attribute/issue_ids", { issue_ids: ["FOO-42"] }); -}); - -it('shows issue id suggestions', async () => { - fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ suggestions: [{ key: "FOO-42", text: "Suggestion" }] }); - renderMetricDebtParameters({ report: { issue_tracker: { type: "Jira", parameters: { url: "https://jira" } } } }) - await userEvent.type(screen.getByLabelText(/Issue identifiers/), 'u'); - expect(screen.queryAllByText(/FOO-42: Suggestion/).length).toBe(1) -}); - -it('shows no issue id suggestions without a query', async () => { - fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ suggestions: [{ key: "FOO-42", text: "Suggestion" }] }); - renderMetricDebtParameters({ report: { issue_tracker: { type: "Jira", parameters: { url: "https://jira" } } } }) - await userEvent.type(screen.getByLabelText(/Issue identifiers/), 's'); - expect(screen.queryAllByText(/FOO-42: Suggestion/).length).toBe(1) - await userEvent.clear(screen.getByLabelText(/Issue identifiers/).firstChild); - expect(screen.queryAllByText(/FOO-42: Suggestion/).length).toBe(0) -}); - it('adds a comment', async () => { renderMetricDebtParameters() await userEvent.type(screen.getByLabelText(/Comment/), 'Keep cool{Tab}'); diff --git a/components/frontend/src/metric/MetricDetails.js b/components/frontend/src/metric/MetricDetails.js index e54859a807..11fb1eda26 100644 --- a/components/frontend/src/metric/MetricDetails.js +++ b/components/frontend/src/metric/MetricDetails.js @@ -17,7 +17,7 @@ import { MetricConfigurationParameters } from './MetricConfigurationParameters'; import { MetricDebtParameters } from './MetricDebtParameters'; import { MetricTypeHeader } from './MetricTypeHeader'; import { TrendGraph } from './TrendGraph'; -import { datePropType, reportPropType, reportsPropType, stringsPropType, stringsURLSearchQueryPropType} from '../sharedPropTypes'; +import { datePropType, reportPropType, reportsPropType, settingsPropType, stringsPropType, stringsURLSearchQueryPropType } from '../sharedPropTypes'; function Buttons({ isFirstMetric, isLastMetric, metric_uuid, reload, stopSorting }) { return ( @@ -53,7 +53,6 @@ fetchMeasurements.propTypes = { setMeasurements: PropTypes.func } - export function MetricDetails({ changed_fields, isFirstMetric, @@ -63,6 +62,7 @@ export function MetricDetails({ report_date, reports, report, + settings, stopSorting, subject_uuid, visibleDetailsTabs, @@ -140,7 +140,7 @@ export function MetricDetails({ const source_name = get_source_name(report_source, dataModel); panes.push({ menuItem: {source_name}, - render: () => + render: () => }); }); } @@ -172,6 +172,7 @@ MetricDetails.propTypes = { report_date: datePropType, reports: reportsPropType, report: reportPropType, + settings: settingsPropType, stopSorting: PropTypes.func, subject_uuid: PropTypes.string, visibleDetailsTabs: stringsURLSearchQueryPropType diff --git a/components/frontend/src/source/SourceEntities.js b/components/frontend/src/source/SourceEntities.js index 6597e6ccfc..d621e42d0d 100644 --- a/components/frontend/src/source/SourceEntities.js +++ b/components/frontend/src/source/SourceEntities.js @@ -4,7 +4,7 @@ import { Button, Icon, Popup, Table } from '../semantic_ui_react_wrappers'; import { SourceEntity } from './SourceEntity'; import { capitalize } from '../utils'; import { DataModel } from '../context/DataModel'; -import { metricPropType, reportPropType, sourcePropType } from '../sharedPropTypes'; +import { metricPropType, reportPropType, settingsPropType, sourcePropType } from '../sharedPropTypes'; export function alignment(attributeType, attributeAlignment) { if (attributeAlignment === "left" || attributeAlignment === "right") { @@ -18,7 +18,7 @@ alignment.propTypes = { attributeAligment: PropTypes.string } -export function SourceEntities({ metric, metric_uuid, reload, report, source }) { +export function SourceEntities({ metric, metric_uuid, reload, report, settings, source }) { const dataModel = useContext(DataModel) const [hideIgnoredEntities, setHideIgnoredEntities] = useState(false); const [sortColumn, setSortColumn] = useState(null); @@ -73,6 +73,9 @@ export function SourceEntities({ metric, metric_uuid, reload, report, source }) > {capitalize(entity_name)} first seen + + Issues + {entity_attributes.map((entity_attribute) => {status === "unconfirmed" ? "" : status_end_date} {status === "unconfirmed" ? "" : rationale} {entity.first_seen ? : ""} + + + {entity_attributes.map((entity_attribute) => diff --git a/components/frontend/src/source/SourceEntityDetails.js b/components/frontend/src/source/SourceEntityDetails.js index 0a4eee5a47..5c229159eb 100644 --- a/components/frontend/src/source/SourceEntityDetails.js +++ b/components/frontend/src/source/SourceEntityDetails.js @@ -9,7 +9,8 @@ import { capitalize } from '../utils'; import { source_entity_status_name as status_name } from './source_entity_status'; import { EDIT_ENTITY_PERMISSION } from '../context/Permissions'; import { LabelWithDate } from '../widgets/LabelWithDate'; -import { entityPropType, entityStatusPropType, reportPropType } from '../sharedPropTypes'; +import { IssuesRows } from '../issue/IssuesRows'; +import { entityPropType, entityStatusPropType, metricPropType, reportPropType } from '../sharedPropTypes'; function entityStatusOption(status, text, content, subheader) { return { @@ -46,6 +47,7 @@ entityStatusOptions.propTypes = { export function SourceEntityDetails( { entity, + metric, metric_uuid, name, rationale, @@ -96,11 +98,13 @@ export function SourceEntityDetails( /> +
); } SourceEntityDetails.propTypes = { entity: entityPropType, + metric: metricPropType, metric_uuid: PropTypes.string, name: PropTypes.string, rationale: PropTypes.string, diff --git a/components/frontend/src/source/SourceEntityDetails.test.js b/components/frontend/src/source/SourceEntityDetails.test.js index 20d7558949..9ab8e9bff9 100644 --- a/components/frontend/src/source/SourceEntityDetails.test.js +++ b/components/frontend/src/source/SourceEntityDetails.test.js @@ -15,9 +15,11 @@ function renderSourceEntityDetails() { render( handleSort(null)} subject_uuid={subject_uuid} visibleDetailsTabs={settings.visibleDetailsTabs} diff --git a/components/frontend/src/utils.js b/components/frontend/src/utils.js index f12b508698..7fa157e3e3 100644 --- a/components/frontend/src/utils.js +++ b/components/frontend/src/utils.js @@ -222,9 +222,16 @@ export function nrMetricsInReports(reports) { return nrMetrics } -export function getMetricIssueIds(metric) { - let issueIds = metric.issue_ids ?? []; - issueIds.sort(); +export function getMetricIssueIds(metric, entityKey) { + let issueIds = []; + if (entityKey) { + issueIds = metric.entity_issue_ids?.[entityKey] ?? []; + } else { + issueIds = metric.issue_ids ?? []; + Object.values(metric?.entity_issue_ids ?? {}).forEach((entityIssueIds) => issueIds.push(...entityIssueIds)) + } + issueIds = [...new Set(issueIds)]; + issueIds.sort((id1, id2) => id1.localeCompare(id2)); return issueIds } diff --git a/docs/src/changelog.md b/docs/src/changelog.md index 8210eab9fc..85958433c6 100644 --- a/docs/src/changelog.md +++ b/docs/src/changelog.md @@ -20,6 +20,7 @@ If your currently installed *Quality-time* version is v4.10.0 or older, please r ### Added +- Support creating issues for specific measurement entities. Closes [#5955](https://github.com/ICTU/quality-time/issues/5955). - Add an endpoint `api/v3/report//metric_status_summary` that returns a summary of the metric statuses for the specified report in JSON format. Closes [#6146](https://github.com/ICTU/quality-time/issues/6146). ## v5.3.1 - 2023-11-08