Skip to content

Commit

Permalink
Support creating issues for specific measurement entities. Closes #5955.
Browse files Browse the repository at this point in the history
  • Loading branch information
fniessink committed Aug 4, 2023
1 parent 183e447 commit 43ceeb4
Show file tree
Hide file tree
Showing 8 changed files with 232 additions and 184 deletions.
111 changes: 111 additions & 0 deletions components/frontend/src/issue/IssuesRows.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import React, { useState } from 'react';
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 { get_metric_issue_ids } from '../utils';

function CreateIssueButton({ issueTrackerConfigured, issueTrackerInstruction, metric_uuid, reload }) {
return (
<ActionButton
action='Create new'
disabled={!issueTrackerConfigured}
fluid
icon='plus'
item_type='issue'
onClick={() => 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'
/>
)
}

function IssueIdentifiers({ issue_tracker_instruction, metric, metric_uuid, report_uuid, reload }) {
const issueStatusHelp = (
<>
<p>Identifiers of issues in the configured issue tracker that track the progress of fixing this metric.</p>
<p>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.</p>
{issue_tracker_instruction}
</>
)
const [suggestions, setSuggestions] = useState([]);
const labelId = `issue-identifiers-label-${metric_uuid}`
const issue_ids = get_metric_issue_ids(metric);
return (
<MultipleChoiceInput
aria-labelledby={labelId}
allowAdditions
onSearchChange={(query) => {
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={<LabelWithHelp labelId={labelId} label="Issue identifiers" help={issueStatusHelp} />}
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 IssuesRows({ 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 : <p>Please configure an issue tracker by expanding the report title, selecting the 'Issue tracker' tab, and configuring an issue tracker.</p>;
return (
<>
<Grid.Row>
<ReadOnlyOrEditable
requiredPermissions={[EDIT_REPORT_PERMISSION]}
readOnlyComponent={
<Grid.Column width={16}>
<IssueIdentifiers issue_tracker_instruction={issueTrackerInstruction} metric={metric} metric_uuid={metric_uuid} report_uuid={report.report_uuid} reload={reload} />
</Grid.Column>
}
editableComponent={
<>
< Grid.Column width={3} verticalAlign="bottom">
<CreateIssueButton issueTrackerConfigured={issueTrackerConfigured} issueTrackerInstruction={issueTrackerInstruction} metric_uuid={metric_uuid} reload={reload} />
</Grid.Column>
<Grid.Column width={13}>
<IssueIdentifiers issue_tracker_instruction={issueTrackerInstruction} metric={metric} metric_uuid={metric_uuid} report_uuid={report.report_uuid} reload={reload} />
</Grid.Column>
</>
}
/>
</Grid.Row>
{(get_metric_issue_ids(metric).length > 0 && !issueTrackerConfigured) &&
<Grid.Row>
<Grid.Column width={16}>
<ErrorMessage title="No issue tracker configured" message={issueTrackerInstruction} />
</Grid.Column>
</Grid.Row>
}
{(metric.issue_status ?? []).filter((issue_status => issue_status.connection_error)).map((issue_status) =>
<Grid.Row key={issue_status.issue_id}>
<Grid.Column width={16}>
<ErrorMessage key={issue_status.issue_id} title={"Connection error while retrieving " + issue_status.issue_id} message={issue_status.connection_error} />
</Grid.Column>
</Grid.Row>
)}
{(metric.issue_status ?? []).filter((issue_status => issue_status.parse_error)).map((issue_status) =>
<Grid.Row key={issue_status.issue_id}>
<Grid.Column width={16}>
<ErrorMessage key={issue_status.issue_id} title={"Parse error while processing " + issue_status.issue_id} message={issue_status.parse_error} />
</Grid.Column>
</Grid.Row>
)}
</>
)
}
112 changes: 112 additions & 0 deletions components/frontend/src/issue/IssuesRows.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import React from 'react';
import { act, waitFor, render, screen, fireEvent } 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(
<Permissions.Provider value={permissions}>
<IssuesRows
metric={
{
type: "violations",
issue_ids: issue_ids,
issue_status: issue_status
}
}
metric_uuid="metric_uuid"
reload={() => {/* Dummy implementation */ }}
report={report}
/>
</Permissions.Provider>
);
}

it('does not show an error message if the metric has no issues and no issue tracker is configured', async () => {
await act(async () => { 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', async () => {
await act(async () => { 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', async () => {
await act(async () => { 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', async () => {
await act(async () => { renderIssuesRow({ issue_ids: ["FOO-42"] }) });
expect(screen.queryAllByText(/No issue tracker configured/).length).toBe(1);
});

it('shows a connection error', async () => {
await act(async () => { 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', async () => {
await act(async () => { 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', async () => {
window.open = jest.fn()
fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: true, error: "", issue_url: "https://tracker/foo-42" });
await act(async () => { 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', async () => {
fetch_server_api.fetch_server_api = jest.fn().mockResolvedValue({ ok: false, error: "Dummy", issue_url: "" });
await act(async () => { 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', async () => {
await act(async () => { 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" }] });
await act(async () => { 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" }] });
await act(async () => { renderIssuesRow({ report: { issue_tracker: { type: "Jira", parameters: { url: "https://jira" } } } }) });
await userEvent.type(screen.getByLabelText(/Issue identifiers/), 'u');
await waitFor(() => { 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" }] });
await act(async () => { renderIssuesRow({ report: { issue_tracker: { type: "Jira", parameters: { url: "https://jira" } } } }) });
await userEvent.type(screen.getByLabelText(/Issue identifiers/), 's');
await waitFor(() => { expect(screen.queryAllByText(/FOO-42: Suggestion/).length).toBe(1) })
await userEvent.clear(screen.getByLabelText(/Issue identifiers/).firstChild);
await waitFor(() => { expect(screen.queryAllByText(/FOO-42: Suggestion/).length).toBe(0) })
});
105 changes: 6 additions & 99 deletions components/frontend/src/metric/MetricDebtParameters.js
Original file line number Diff line number Diff line change
@@ -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 { get_metric_issue_ids } from '../utils';
import { EDIT_REPORT_PERMISSION } from '../context/Permissions';
import { IssuesRows } from '../issue/IssuesRows';
import { Target } from './Target';

function AcceptTechnicalDebt({ metric, metric_uuid, reload }) {
Expand Down Expand Up @@ -59,54 +54,15 @@ function TechnicalDebtEndDate({ metric, metric_uuid, reload }) {
<DateInput
ariaLabelledBy={labelId}
requiredPermissions={[EDIT_REPORT_PERMISSION]}
label=<LabelWithDate date={metric.debt_end_date} labelId={labelId} help={help} label="Technical debt end date"/>
label=<LabelWithDate date={metric.debt_end_date} labelId={labelId} help={help} label="Technical debt end date" />
placeholder="YYYY-MM-DD"
set_value={(value) => set_metric_attribute(metric_uuid, "debt_end_date", value, reload)}
value={metric.debt_end_date ?? ""}
/>
)
}

function IssueIdentifiers({ issue_tracker_instruction, metric, metric_uuid, report_uuid, reload }) {
const issueStatusHelp = (
<>
<p>Identifiers of issues in the configured issue tracker that track the progress of fixing this metric.</p>
<p>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.</p>
{issue_tracker_instruction}
</>
)
const [suggestions, setSuggestions] = useState([]);
const labelId = `issue-identifiers-label-${metric_uuid}`
const issue_ids = get_metric_issue_ids(metric);
return (
<MultipleChoiceInput
aria-labelledby={labelId}
allowAdditions
onSearchChange={(query) => {
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={<LabelWithHelp labelId={labelId} label="Issue identifiers" help={issueStatusHelp} />}
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 : <p>Please configure an issue tracker by expanding the report title, selecting the 'Issue tracker' tab, and configuring an issue tracker.</p>;
return (
<Grid stackable columns={3}>
<Grid.Row>
Expand All @@ -120,56 +76,7 @@ export function MetricDebtParameters({ report, metric, metric_uuid, reload }) {
<TechnicalDebtEndDate metric={metric} metric_uuid={metric_uuid} reload={reload} />
</Grid.Column>
</Grid.Row>
<Grid.Row>
<ReadOnlyOrEditable
requiredPermissions={[EDIT_REPORT_PERMISSION]}
readOnlyComponent={
<Grid.Column width={16}>
<IssueIdentifiers issue_tracker_instruction={issueTrackerInstruction} metric={metric} metric_uuid={metric_uuid} report_uuid={report.report_uuid} reload={reload} />
</Grid.Column>
}
editableComponent={
<>
< Grid.Column width={3} verticalAlign="bottom">
<ActionButton
action='Create new'
disabled={!issueTrackerConfigured}
fluid
icon='plus'
item_type='issue'
onClick={() => add_metric_issue(metric_uuid, reload)}
popup={<p>Create a new issue for this metric in the configured issue tracker and add its identifier to the tracked issue identifiers.{issueTrackerInstruction}</p>}
position='top center'
/>
</Grid.Column>
<Grid.Column width={13}>
<IssueIdentifiers issue_tracker_instruction={issueTrackerInstruction} metric={metric} metric_uuid={metric_uuid} report_uuid={report.report_uuid} reload={reload} />
</Grid.Column>
</>
}
/>
</Grid.Row>
{(get_metric_issue_ids(metric).length > 0 && !issueTrackerConfigured) &&
<Grid.Row>
<Grid.Column width={16}>
<ErrorMessage title="No issue tracker configured" message={issueTrackerInstruction} />
</Grid.Column>
</Grid.Row>
}
{(metric.issue_status ?? []).filter((issue_status => issue_status.connection_error)).map((issue_status) =>
<Grid.Row key={issue_status.issue_id}>
<Grid.Column width={16}>
<ErrorMessage key={issue_status.issue_id} title={"Connection error while retrieving " + issue_status.issue_id} message={issue_status.connection_error} />
</Grid.Column>
</Grid.Row>
)}
{(metric.issue_status ?? []).filter((issue_status => issue_status.parse_error)).map((issue_status) =>
<Grid.Row key={issue_status.issue_id}>
<Grid.Column width={16}>
<ErrorMessage key={issue_status.issue_id} title={"Parse error while processing " + issue_status.issue_id} message={issue_status.parse_error} />
</Grid.Column>
</Grid.Row>
)}
<IssuesRows metric={metric} metric_uuid={metric_uuid} reload={reload} report={report} />
<Grid.Row>
<Grid.Column width={16}>
<Comment
Expand Down
Loading

0 comments on commit 43ceeb4

Please sign in to comment.