Skip to content

Commit

Permalink
Show ignored measurement entity overview in popup. (#9572)
Browse files Browse the repository at this point in the history
Show the number of ignored measurement entities in the measurement value popup.

Closes #7626.
  • Loading branch information
fniessink authored Sep 9, 2024
1 parent 3daec24 commit c7a79ec
Show file tree
Hide file tree
Showing 12 changed files with 138 additions and 23 deletions.
2 changes: 1 addition & 1 deletion components/api_server/src/database/measurements.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

# Projection options
NO_ID = {"_id": False}
NO_SOURCE_DETAILS = NO_ID | {"sources.entities": False, "sources.entity_user_data": False}
NO_SOURCE_DETAILS = NO_ID | {"sources.entities": False}
NO_MEASUREMENT_DETAILS = NO_SOURCE_DETAILS | {"issue_status": False}

# Sort optins
Expand Down
5 changes: 5 additions & 0 deletions components/frontend/src/measurement/MeasurementValue.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@media print {
.hide.icon {
display: none !important;
}
}
84 changes: 75 additions & 9 deletions components/frontend/src/measurement/MeasurementValue.js
Original file line number Diff line number Diff line change
@@ -1,39 +1,88 @@
import "./MeasurementValue.css"

import { bool, string } from "prop-types"
import { useContext } from "react"
import { Icon } from "semantic-ui-react"

import { DataModel } from "../context/DataModel"
import { Label, Popup } from "../semantic_ui_react_wrappers"
import { datePropType, metricPropType } from "../sharedPropTypes"
import { Icon, Label, Message, Popup } from "../semantic_ui_react_wrappers"
import { datePropType, measurementPropType, metricPropType } from "../sharedPropTypes"
import { IGNORABLE_SOURCE_ENTITY_STATUSES, SOURCE_ENTITY_STATUS_NAME } from "../source/source_entity_status"
import {
formatMetricValue,
getMetricScale,
getMetricUnit,
getMetricValue,
isMeasurementRequested,
MILLISECONDS_PER_HOUR,
sum,
} from "../utils"
import { TimeAgoWithDate } from "../widgets/TimeAgoWithDate"
import { WarningMessage } from "../widgets/WarningMessage"

function measurementValueLabel(stale, updating, value) {
function measurementValueLabel(hasIgnoredEntities, stale, updating, value) {
const measurementValue = hasIgnoredEntities ? (
<>
<Icon name="hide" /> {value}
</>
) : (
value
)
if (stale) {
return <Label color="red">{value}</Label>
return <Label color="red">{measurementValue}</Label>
}
if (updating) {
return (
<Label color="yellow">
<Icon loading name="hourglass" /> {value}
<Icon loading name="hourglass" /> {measurementValue}
</Label>
)
}
return <span>{value}</span>
return <span>{measurementValue}</span>
}
measurementValueLabel.propTypes = {
stale: bool,
hasIgnoredEntities: bool,
updating: bool,
stale: bool,
value: string,
}

function ignoredEntitiesCount(measurement) {
const count = Object.fromEntries(IGNORABLE_SOURCE_ENTITY_STATUSES.map((status) => [status, 0]))
measurement.sources?.forEach((source) => {
Object.values(source.entity_user_data ?? {}).forEach((entity) => {
if (Object.keys(count).includes(entity.status)) {
count[entity.status]++
}
})
})
return count
}
ignoredEntitiesCount.propTypes = {
measurement: measurementPropType,
}

function ignoredEntitiesMessage(measurement, unit) {
const count = ignoredEntitiesCount(measurement)
let summary = `The measurement value excludes ${sum(count)} ${unit}.`
let details = ""
Object.entries(count).forEach(([status, status_count]) => {
if (status_count > 0) {
details += `Marked as ${SOURCE_ENTITY_STATUS_NAME[status].toLowerCase()}: ${status_count}. `
}
})
return (
<p>
{summary}
<br />
{details}
</p>
)
}
ignoredEntitiesMessage.propTypes = {
measurement: measurementPropType,
unit: string,
}

export function MeasurementValue({ metric, reportDate }) {
const dataModel = useContext(DataModel)
const metricValue = getMetricValue(metric, dataModel)
Expand All @@ -43,14 +92,20 @@ export function MeasurementValue({ metric, reportDate }) {
value += "%"
}
value = formatMetricValue(scale, value)
const unit = getMetricUnit(metric, dataModel)
if (metric.latest_measurement) {
const end = new Date(metric.latest_measurement.end)
const now = reportDate ?? new Date()
const stale = now - end > MILLISECONDS_PER_HOUR // No new measurement for more than one hour means something is wrong
const outdated = metric.latest_measurement.outdated ?? false
const requested = isMeasurementRequested(metric)
const hasIgnoredEntities = sum(ignoredEntitiesCount(metric.latest_measurement)) > 0
return (
<Popup trigger={measurementValueLabel(stale, outdated || requested, value)} flowing hoverable>
<Popup
trigger={measurementValueLabel(hasIgnoredEntities, stale, outdated || requested, value)}
flowing
hoverable
>
<WarningMessage
showIf={stale}
header="This metric was not recently measured"
Expand All @@ -66,6 +121,17 @@ export function MeasurementValue({ metric, reportDate }) {
header="Measurement requested"
content="An update of the latest measurement was requested by a user."
/>
{hasIgnoredEntities && (
<Message
info
header={
<span>
<Icon name="hide" /> {`Ignored ${unit}`}
</span>
}
content={ignoredEntitiesMessage(metric.latest_measurement, unit)}
/>
)}
<TimeAgoWithDate date={metric.latest_measurement.end}>
{metric.status ? "The metric was last measured" : "Last measurement attempt"}
</TimeAgoWithDate>
Expand Down
24 changes: 24 additions & 0 deletions components/frontend/src/measurement/MeasurementValue.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ it("renders a value for which a measurement was requested, but which is now up t
it("renders a minutes value", () => {
renderMeasurementValue({
type: "duration",
unit: "foo units",
latest_measurement: { count: { value: "42" } },
})
expect(screen.getAllByText(/42/).length).toBe(1)
Expand All @@ -108,6 +109,7 @@ it("renders a minutes value", () => {
it("renders an unknown minutes value", () => {
renderMeasurementValue({
type: "duration",
unit: "foo units",
latest_measurement: { count: { value: null } },
})
expect(screen.getAllByText(/\?/).length).toBe(1)
Expand All @@ -117,6 +119,7 @@ it("renders a minutes percentage", () => {
renderMeasurementValue({
type: "duration",
scale: "percentage",
unit: "foo units",
latest_measurement: { percentage: { value: "42" } },
})
expect(screen.getAllByText(/42%/).length).toBe(1)
Expand All @@ -126,6 +129,7 @@ it("renders an unknown minutes percentage", () => {
renderMeasurementValue({
type: "duration",
scale: "percentage",
unit: "foo units",
latest_measurement: { percentage: { value: null } },
})
expect(screen.getAllByText(/\?%/).length).toBe(1)
Expand Down Expand Up @@ -165,3 +169,23 @@ it("does not show an error message for past measurements that were recently meas
expect(screen.queryByText(/Last measurement attempt/)).not.toBe(null)
})
})

it("shows ignored measurement entities", async () => {
renderMeasurementValue({
status: "target_met",
unit: "foo",
latest_measurement: {
start: "2022-01-16T00:31:00",
end: "2022-01-16T00:51:00",
count: { value: "42" },
sources: [
{ entity_user_data: { entity1: { status: "false_positive" }, entity2: { status: "confirmed" } } },
{},
],
},
})
await userEvent.hover(screen.queryByText(/42/))
await waitFor(() => {
expect(screen.queryByText(/Ignored foo/)).not.toBe(null)
})
})
5 changes: 5 additions & 0 deletions components/frontend/src/sharedPropTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,12 @@ export const issueStatusPropType = shape({
updated: string,
})

const entityUserDataPropType = shape({
status: entityStatusPropType,
})

export const measurementSourcePropType = shape({
entity_user_date: entityUserDataPropType,
connection_error: string,
parse_error: string,
})
Expand Down
6 changes: 6 additions & 0 deletions components/frontend/src/source/SourceEntities.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@
top: 187px;
z-index: 1;
}

@media print {
button.ui {
display: none !important;
}
}
2 changes: 1 addition & 1 deletion components/frontend/src/source/SourceEntities.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ function sourceEntitiesHeaders(
) {
const entityName = metricEntities.name
const entityNamePlural = metricEntities.name_plural
const hideIgnoredEntitiesLabel = `${hideIgnoredEntities ? "Show" : "Hide"} resolved ${entityNamePlural}`
const hideIgnoredEntitiesLabel = `${hideIgnoredEntities ? "Show" : "Hide"} ignored ${entityNamePlural}`
return (
<Table.Row>
<Table.HeaderCell collapsing textAlign="center">
Expand Down
8 changes: 4 additions & 4 deletions components/frontend/src/source/SourceEntities.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,17 +146,17 @@ it("renders a message if there are no measurements", () => {
expect(screen.getAllByText(/No measurements/).length).toBe(1)
})

it("shows the hide resolved entities button", async () => {
it("shows the hide ignored entities button", async () => {
renderSourceEntities()
const hideEntitiesButton = screen.getAllByRole("button")[0]
expect(hideEntitiesButton).toHaveAttribute("aria-label", "Hide resolved entities")
expect(hideEntitiesButton).toHaveAttribute("aria-label", "Hide ignored entities")
})

it("shows the show resolved entities button", async () => {
it("shows the show ignored entities button", async () => {
renderSourceEntities()
const hideEntitiesButton = screen.getAllByRole("button")[0]
await userEvent.click(hideEntitiesButton)
expect(hideEntitiesButton).toHaveAttribute("aria-label", "Show resolved entities")
expect(hideEntitiesButton).toHaveAttribute("aria-label", "Show ignored entities")
})

it("sorts the entities by status", async () => {
Expand Down
4 changes: 2 additions & 2 deletions components/frontend/src/source/SourceEntity.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Table } from "semantic-ui-react"
import { entityAttributesPropType, entityPropType, entityStatusPropType, reportPropType } from "../sharedPropTypes"
import { TableRowWithDetails } from "../widgets/TableRowWithDetails"
import { TimeAgoWithDate } from "../widgets/TimeAgoWithDate"
import { SOURCE_ENTITY_STATUS_NAME } from "./source_entity_status"
import { IGNORABLE_SOURCE_ENTITY_STATUSES, SOURCE_ENTITY_STATUS_NAME } from "./source_entity_status"
import { alignment } from "./SourceEntities"
import { SourceEntityAttribute } from "./SourceEntityAttribute"
import { SourceEntityDetails } from "./SourceEntityDetails"
Expand All @@ -18,7 +18,7 @@ function entityCanBeIgnored(status, statusEndDateString) {
if (statusEndDate < now) {
return false
}
return ["wont_fix", "fixed", "false_positive"].includes(status)
return IGNORABLE_SOURCE_ENTITY_STATUSES.includes(status)
}
entityCanBeIgnored.propTypes = {
status: entityStatusPropType,
Expand Down
8 changes: 5 additions & 3 deletions components/frontend/src/source/source_entity_status.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export const IGNORABLE_SOURCE_ENTITY_STATUSES = ["false_positive", "fixed", "wont_fix"]

export const SOURCE_ENTITY_STATUS_NAME = {
unconfirmed: "Unconfirmed",
confirmed: "Confirmed",
Expand All @@ -9,9 +11,9 @@ export const SOURCE_ENTITY_STATUS_NAME = {
export const SOURCE_ENTITY_STATUS_ACTION = {
unconfirmed: "Unconfirm",
confirmed: "Confirm",
fixed: "Resolve as fixed",
false_positive: "Resolve as false positive",
wont_fix: "Resolve as won't fix",
fixed: "Mark as fixed",
false_positive: "Mark as false positive",
wont_fix: "Mark as won't fix",
}

export const SOURCE_ENTITY_STATUS_DESCRIPTION = {
Expand Down
12 changes: 9 additions & 3 deletions components/frontend/src/subject/SubjectTableHeader.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,9 @@ const measurementHelp = (
<>
<p>The latest measurement value. Metrics are measured periodically.</p>
<p>
If the measurement value is &lsquo?&rsquo, no sources have been configured for the metric yet or the
measurement data could not be collected. Expand the metric (click <Icon fitted name="triangle right" />) and
navigate to the sources tab to add sources or see the error details.
If the measurement value is ?, no sources have been configured for the metric yet or the measurement data
could not be collected. Expand the metric (click <Icon fitted name="triangle right" />) and navigate to the
sources tab to add sources or see the error details.
</p>
<p>
If the measurement value has a{" "}
Expand All @@ -76,6 +76,12 @@ const measurementHelp = (
, the metric has not been measured recently. This indicates a problem with <em>Quality-time</em> itself, and
a system administrator should be notified.
</p>
<p>
If there is a <Icon name="hide" /> before the measurement value, it means one or more measurement entities
are being ignored. Hover over the measurement value to see how many entities are ignored. Expand the metric
(click <Icon fitted name="triangle right" />) and navigate to the entities tab to see why individual
entities are ignored.
</p>
<p>Hover over the measurement value to see when the metric was last measured.</p>
<p>Click the column header to sort the metrics by measurement value.</p>
</>
Expand Down
1 change: 1 addition & 0 deletions docs/src/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ If your currently installed *Quality-time* version is not v5.15.0, please first
### Added

- Allow for configuring Jenkins as source for the metric 'CI-pipeline duration' (GitLab CI was already supported, Azure DevOps will follow later). Partially implements [#6423](https://github.com/ICTU/quality-time/issues/6423).
- Show the number of ignored measurement entities (entities marked as "False positive, "Won't fix" or "Will be fixed") in the measurement value popup. Closes [#7626](https://github.com/ICTU/quality-time/issues/7626).
- Add GitHub as possible source for the 'merge requests' metric. Patch contributed by Tobias Termeczky (the/experts). Closes [#9323](https://github.com/ICTU/quality-time/issues/9323).

## v5.15.0 - 2024-07-30
Expand Down

0 comments on commit c7a79ec

Please sign in to comment.