Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Migrate SUIR components to MUI.
Browse files Browse the repository at this point in the history
Closes #9796.
fniessink committed Jan 12, 2025
1 parent 8d88700 commit 998ea21
Showing 182 changed files with 3,181 additions and 5,266 deletions.
189 changes: 3 additions & 186 deletions components/frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 0 additions & 3 deletions components/frontend/package.json
Original file line number Diff line number Diff line change
@@ -12,18 +12,15 @@
"@mui/x-date-pickers": "^7.23.6",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.13",
"fomantic-ui-css": "^2.9.3",
"history": "^5.3.0",
"prop-types": "^15.8.1",
"react": "^18.3.1",
"react-datepicker": "^7.6.0",
"react-dom": "^18.3.1",
"react-grid-layout": "^1.5.0",
"react-hash-link": "1.0.2",
"react-is": "^18.3.1",
"react-timeago": "^7.2.0",
"react-toastify": "^11.0.2",
"semantic-ui-react": "^2.1.5",
"victory": "^37.3.5"
},
"scripts": {
20 changes: 1 addition & 19 deletions components/frontend/src/App.css
Original file line number Diff line number Diff line change
@@ -1,21 +1,3 @@
.MainContainer {
flex: 1;
margin-top: 6em;
padding-left: 1em;
padding-right: 1em;
}

@media print {
.MainContainer {
margin-top: 0em;
}
}

html {
scroll-padding-top: 163px; /* height of sticky header */
}

:root {
--inverted-menu-background-color: #1b1c1d;
--selection-color: #2185d0;
scroll-padding-top: 176px; /* height of sticky header */
}
12 changes: 4 additions & 8 deletions components/frontend/src/App.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import "./App.css"

import { createTheme, ThemeProvider } from "@mui/material/styles"
import { CssBaseline } from "@mui/material"
import { ThemeProvider } from "@mui/material/styles"
import { Action } from "history"
import history from "history/browser"
import { Component } from "react"
@@ -11,16 +12,10 @@ import { nr_measurements_api } from "./api/measurement"
import { get_report, get_reports_overview } from "./api/report"
import { AppUI } from "./AppUI"
import { registeredURLSearchParams } from "./hooks/url_search_query"
import { theme } from "./theme"
import { isValidDate_YYYYMMDD, toISODateStringInCurrentTZ } from "./utils"
import { showConnectionMessage, showMessage } from "./widgets/toast"

const theme = createTheme({
colorSchemes: {
dark: true, // Add a dark theme (light theme is available by default)
},
components: { MuiTooltip: { defaultProps: { arrow: true }, styleOverrides: { tooltip: { fontSize: "1em" } } } },
})

class App extends Component {
constructor(props) {
super(props)
@@ -245,6 +240,7 @@ class App extends Component {
render() {
return (
<ThemeProvider theme={theme}>
<CssBaseline enableColorScheme />
<AppUI
changed_fields={this.changed_fields}
dataModel={this.state.dataModel}
106 changes: 48 additions & 58 deletions components/frontend/src/AppUI.js
Original file line number Diff line number Diff line change
@@ -66,65 +66,55 @@ export function AppUI({
}

const darkMode = userPrefersDarkMode(mode)
const backgroundColor = darkMode ? "rgb(40, 40, 40)" : "white"
return (
<div
style={{
display: "flex",
minHeight: "100vh",
flexDirection: "column",
backgroundColor: backgroundColor,
}}
>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={locale_en_gb}>
<DarkMode.Provider value={darkMode}>
<HashLinkObserver />
<Menubar
email={email}
handleDateChange={handleDateChange}
openReportsOverview={openReportsOverview}
onDate={handleDateChange}
report_date={report_date}
report_uuid={report_uuid}
set_user={set_user}
user={user}
panel={
<SettingsPanel
atReportsOverview={atReportsOverview}
handleSort={handleSort}
settings={settings}
tags={getReportsTags(reports)}
/>
}
settings={settings}
setUIMode={setMode}
uiMode={mode}
/>
<ToastContainer theme="colored" />
<Permissions.Provider value={user_permissions}>
<DataModel.Provider value={dataModel}>
<PageContent
changed_fields={changed_fields}
current_report={current_report}
handleSort={handleSort}
lastUpdate={lastUpdate}
loading={loading}
nrMeasurements={nrMeasurements}
openReportsOverview={openReportsOverview}
openReport={openReport}
reload={reload}
report_date={report_date}
report_uuid={report_uuid}
reports={reports}
reports_overview={reports_overview}
settings={settings}
/>
</DataModel.Provider>
</Permissions.Provider>
<Footer lastUpdate={lastUpdate} report={current_report} />
</DarkMode.Provider>
</LocalizationProvider>
</div>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={locale_en_gb}>
<DarkMode.Provider value={darkMode}>
<HashLinkObserver />
<Menubar
email={email}
handleDateChange={handleDateChange}
openReportsOverview={openReportsOverview}
onDate={handleDateChange}
report_date={report_date}
report_uuid={report_uuid}
set_user={set_user}
user={user}
panel={
<SettingsPanel
atReportsOverview={atReportsOverview}
handleSort={handleSort}
settings={settings}
tags={getReportsTags(reports)}
/>
}
settings={settings}
setUIMode={setMode}
uiMode={mode}
/>
<ToastContainer theme="colored" />
<Permissions.Provider value={user_permissions}>
<DataModel.Provider value={dataModel}>
<PageContent
changed_fields={changed_fields}
current_report={current_report}
handleSort={handleSort}
lastUpdate={lastUpdate}
loading={loading}
nrMeasurements={nrMeasurements}
openReportsOverview={openReportsOverview}
openReport={openReport}
reload={reload}
report_date={report_date}
report_uuid={report_uuid}
reports={reports}
reports_overview={reports_overview}
settings={settings}
/>
</DataModel.Provider>
</Permissions.Provider>
<Footer lastUpdate={lastUpdate} report={current_report} />
</DarkMode.Provider>
</LocalizationProvider>
)
}
AppUI.propTypes = {
35 changes: 29 additions & 6 deletions components/frontend/src/PageContent.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Box, Container } from "@mui/material"
import CircularProgress from "@mui/material/CircularProgress"
import { bool, func, number, string } from "prop-types"
import { useEffect, useState } from "react"
import { Container, Loader } from "semantic-ui-react"

import { get_measurements } from "./api/measurement"
import { Report } from "./report/Report"
import { ReportsOverview } from "./report/ReportsOverview"
import { Segment } from "./semantic_ui_react_wrappers"
import {
datePropType,
optionalDatePropType,
@@ -72,9 +72,17 @@ export function PageContent({
let content
if (loading) {
content = (
<Segment basic placeholder aria-label="Loading...">
<Loader active size="massive" />
</Segment>
<Box
sx={{
alignItems: "center",
display: "flex",
width: "100%",
height: "60vh",
justifyContent: "center",
}}
>
<CircularProgress aria-label="Loading..." size="6rem" />
</Box>
)
} else {
const commonProps = {
@@ -108,7 +116,22 @@ export function PageContent({
}
}
return (
<Container fluid className="MainContainer">
<Container
disableGutters
maxWidth={false}
sx={{
bgcolor: "background.default",
flex: 1,
paddingBottom: "50px",
paddingTop: "10px",
paddingLeft: "20px",
paddingRight: "20px",
marginTop: "60px",
marginBottom: "0px",
marginLeft: "0px",
marginRight: "0px",
}}
>
{content}
</Container>
)
2 changes: 1 addition & 1 deletion components/frontend/src/PageContent.test.js
Original file line number Diff line number Diff line change
@@ -63,7 +63,7 @@ it("shows that the report was missing", async () => {

it("shows the loading spinner", async () => {
await renderPageContent({ loading: true })
expect(screen.getAllByLabelText(/Loading/).length).toBe(1)
expect(screen.getAllByRole("progressbar").length).toBe(1)
})

function expectMeasurementsCall(date, offset = 0) {
26 changes: 0 additions & 26 deletions components/frontend/src/app_ui_settings.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { string } from "prop-types"

import {
useArrayURLSearchQuery,
useBooleanURLSearchQuery,
useIntegerURLSearchQuery,
useStringURLSearchQuery,
} from "./hooks/url_search_query"
import { stringsURLSearchQueryPropType } from "./sharedPropTypes"

function urlSearchQueryKey(key, report_uuid) {
// Make the settings changeable per report (and separately for the reports overview) by adding the report UUID as
@@ -147,26 +144,3 @@ export function allSettingsAreDefault(settings) {
settings.sortDirection.isDefault()
)
}

export function tabChangeHandler(expandedItems, uuid) {
// Return an event handler for Tab.onTabChange that updates the active tab
return function onTabChange(_event, data) {
const oldItem = expandedItems.value.filter((item) => item?.startsWith(uuid))[0]
const newItem = `${uuid}:${data.activeIndex}`
expandedItems.toggle(oldItem, newItem)
}
}
tabChangeHandler.propTypes = {
expandedItems: stringsURLSearchQueryPropType,
uuid: string,
}

export function activeTabIndex(expandedItems, uuid) {
// Return the active tab index of the expanded item, defaults to 0
const item = expandedItems.value.filter((item) => item?.startsWith(uuid))[0] ?? `${uuid}:0`
return Number(item.split(":")[1])
}
activeTabIndex.propTypes = {
expandedItems: stringsURLSearchQueryPropType,
uuid: string,
}
7 changes: 5 additions & 2 deletions components/frontend/src/context/Permissions.js
Original file line number Diff line number Diff line change
@@ -10,10 +10,13 @@ export const PERMISSIONS = [EDIT_REPORT_PERMISSION, EDIT_ENTITY_PERMISSION]
export const Permissions = React.createContext(null)

export function accessGranted(permissions, requiredPermissions) {
if (!requiredPermissions) {
if (!(requiredPermissions instanceof Array)) {
return false
}
if (requiredPermissions.length === 0) {
return true
}
if (!permissions) {
if ((permissions ?? []).length === 0) {
return false
}
return requiredPermissions.every((permission) => permissions.includes(permission))
24 changes: 24 additions & 0 deletions components/frontend/src/context/Permissions.test.js
Original file line number Diff line number Diff line change
@@ -50,3 +50,27 @@ it("shows the editable only component", () => {
expect(screen.queryAllByText("One").length).toBe(0)
expect(screen.queryAllByText("Two").length).toBe(1)
})

it("shows the editable only component if no permissions are needed", () => {
render(
<Permissions.Provider value={["mockPermission"]}>
<ReadOnlyOrEditable
requiredPermissions={[]}
readOnlyComponent={<MockComponent1 />}
editableComponent={<MockComponent2 />}
/>
</Permissions.Provider>,
)
expect(screen.queryAllByText("One").length).toBe(0)
expect(screen.queryAllByText("Two").length).toBe(1)
})

it("shows the read-only component if required permissions are missing", () => {
render(
<Permissions.Provider value={["mockPermission"]}>
<ReadOnlyOrEditable readOnlyComponent={<MockComponent1 />} editableComponent={<MockComponent2 />} />
</Permissions.Provider>,
)
expect(screen.queryAllByText("One").length).toBe(1)
expect(screen.queryAllByText("Two").length).toBe(0)
})
13 changes: 5 additions & 8 deletions components/frontend/src/dashboard/CardDashboard.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react"

import { DarkMode } from "../context/DarkMode"
import { EDIT_REPORT_PERMISSION, Permissions } from "../context/Permissions"
import { CardDashboard } from "./CardDashboard"
import { MetricSummaryCard } from "./MetricSummaryCard"
@@ -12,13 +11,11 @@ afterEach(() => jest.restoreAllMocks())

function renderCardDashboard({ cards = [], initialLayout = [], saveLayout = jest.fn } = {}) {
return render(
<DarkMode.Provider value={false}>
<Permissions.Provider value={[EDIT_REPORT_PERMISSION]}>
<div id="dashboard">
<CardDashboard cards={cards} initialLayout={initialLayout} saveLayout={saveLayout} />
</div>
</Permissions.Provider>
</DarkMode.Provider>,
<Permissions.Provider value={[EDIT_REPORT_PERMISSION]}>
<div id="dashboard">
<CardDashboard cards={cards} initialLayout={initialLayout} saveLayout={saveLayout} />
</div>
</Permissions.Provider>,
)
}

35 changes: 0 additions & 35 deletions components/frontend/src/dashboard/ExportCard.css

This file was deleted.

79 changes: 0 additions & 79 deletions components/frontend/src/dashboard/ExportCard.js

This file was deleted.

6 changes: 3 additions & 3 deletions components/frontend/src/dashboard/FilterCardWithTable.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { Table, TableBody } from "@mui/material"
import { bool, func, string } from "prop-types"

import { Table } from "../semantic_ui_react_wrappers"
import { childrenPropType } from "../sharedPropTypes"
import { DashboardCard } from "./DashboardCard"

export function FilterCardWithTable({ children, onClick, selected, title }) {
return (
<DashboardCard onClick={onClick} selected={selected} title={title} titleFirst={true}>
<Table basic="very" compact="very" size="small">
<Table.Body>{children}</Table.Body>
<Table size="small">
<TableBody>{children}</TableBody>
</Table>
</DashboardCard>
)
23 changes: 8 additions & 15 deletions components/frontend/src/dashboard/IssuesCard.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { Chip } from "@mui/material"
import { Chip, TableCell, TableRow } from "@mui/material"
import { bool, func } from "prop-types"

import { Table } from "../semantic_ui_react_wrappers"
import { reportPropType } from "../sharedPropTypes"
import { capitalize, ISSUE_STATUS_THEME_COLORS } from "../utils"
import { capitalize } from "../utils"
import { FilterCardWithTable } from "./FilterCardWithTable"

function issueStatuses(report) {
@@ -33,18 +32,12 @@ issueStatuses.propTypes = {
function tableRows(report) {
const statuses = issueStatuses(report)
return Object.keys(statuses).map((status) => (
<Table.Row key={status}>
<Table.Cell>{capitalize(status)}</Table.Cell>
<Table.Cell textAlign="right">
<Chip
color={ISSUE_STATUS_THEME_COLORS[status]}
label={`${statuses[status]}`}
size="small"
sx={{ borderRadius: 1 }}
variant={ISSUE_STATUS_THEME_COLORS[status] ? "" : "outlined"}
/>
</Table.Cell>
</Table.Row>
<TableRow key={status}>
<TableCell sx={{ fontSize: "12px", paddingLeft: "0px" }}>{capitalize(status)}</TableCell>
<TableCell sx={{ paddingRight: "0px", textAlign: "right" }}>
<Chip color={status} label={`${statuses[status]}`} size="small" sx={{ borderRadius: 1 }} />
</TableCell>
</TableRow>
))
}
tableRows.propTypes = {
9 changes: 6 additions & 3 deletions components/frontend/src/dashboard/LegendCard.js
Original file line number Diff line number Diff line change
@@ -8,14 +8,17 @@ export function LegendCard() {
const listItems = STATUSES.map((status) => (
<ListItem key={status} dense={true} sx={{ padding: "0px" }}>
<StatusIcon status={status} size="small" />
&nbsp;
<ListItemText primary={STATUS_SHORT_NAME[status]} primaryTypographyProps={{ noWrap: true }} />
<ListItemText
primary={STATUS_SHORT_NAME[status]}
slotProps={{ primary: { typography: { fontSize: "11px" } } }}
sx={{ marginLeft: "10px" }}
/>
</ListItem>
))

return (
<DashboardCard title="Legend" titleFirst={true}>
<List sx={{ padding: "0px", paddingLeft: "16px" }}>{listItems}</List>
<List sx={{ padding: "0px", paddingLeft: "0px" }}>{listItems}</List>
</DashboardCard>
)
}
9 changes: 2 additions & 7 deletions components/frontend/src/dashboard/MetricSummaryCard.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import "./MetricSummaryCard.css"

import { bool, func, number, object, oneOfType, string } from "prop-types"
import { useContext } from "react"
import { VictoryContainer, VictoryLabel, VictoryTooltip } from "victory"

import { DarkMode } from "../context/DarkMode"
import { useBoundingBox } from "../hooks/boundingbox"
import { STATUS_COLORS_RGB, STATUSES } from "../metric/status"
import { pluralize, sum } from "../utils"
@@ -48,8 +46,6 @@ function ariaChartLabel(summary) {

export function MetricSummaryCard({ header, onClick, selected, summary, maxY }) {
const [boundingBox, ref] = useBoundingBox()
const labelColor = useContext(DarkMode) ? "darkgrey" : "rgba(120, 120, 120)"
const flyoutBgColor = useContext(DarkMode) ? "rgba(60, 65, 70)" : "white"
const animate = { duration: 0, onLoad: { duration: 0 } }
const colors = STATUSES.map((status) => STATUS_COLORS_RGB[status])
const bbWidth = boundingBox.width ?? 0
@@ -60,9 +56,8 @@ export function MetricSummaryCard({ header, onClick, selected, summary, maxY })
constrainToVisibleArea={true}
cornerRadius={4}
flyoutHeight={54} // If we don't pass this, a height is calculated by Victory, but it's much too high
flyoutStyle={{ fill: flyoutBgColor }}
renderInPortal={false}
style={{ fontFamily: "Arial", fontSize: 16, fill: labelColor }}
style={{ fontFamily: "Arial", fontSize: 16 }}
/>
)
const dates = Object.keys(summary)
@@ -72,7 +67,7 @@ export function MetricSummaryCard({ header, onClick, selected, summary, maxY })
height: Math.max(bbHeight, 1), // Prevent "Failed prop type: Invalid prop range supplied to VictoryBar"
label: (
<VictoryLabel
style={{ fill: labelColor }}
style={{ fill: "grey" }}
text={nrMetricsLabel(sum(summary[dates[0]]))}
textAnchor="middle"
x={bbWidth / 2}
34 changes: 15 additions & 19 deletions components/frontend/src/dashboard/MetricsRequiringActionCard.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Chip, TableCell, TableRow } from "@mui/material"
import { bool, func } from "prop-types"

import { STATUS_COLORS, STATUS_NAME, STATUSES_REQUIRING_ACTION } from "../metric/status"
import { Label, Table } from "../semantic_ui_react_wrappers"
import { STATUS_NAME, STATUSES_REQUIRING_ACTION } from "../metric/status"
import { reportsPropType } from "../sharedPropTypes"
import { getMetricStatus, sum } from "../utils"
import { FilterCardWithTable } from "./FilterCardWithTable"
@@ -30,26 +30,22 @@ metricStatuses.propTypes = {
function tableRows(reports) {
const statuses = metricStatuses(reports)
const rows = Object.keys(statuses).map((status) => (
<Table.Row key={status}>
<Table.Cell>{STATUS_NAME[status]}</Table.Cell>
<Table.Cell textAlign="right">
<Label size="small" color={STATUS_COLORS[status] === "white" ? null : STATUS_COLORS[status]}>
{statuses[status]}
</Label>
</Table.Cell>
</Table.Row>
<TableRow key={status}>
<TableCell sx={{ fontSize: "12px", paddingLeft: "0px" }}>{STATUS_NAME[status]}</TableCell>
<TableCell sx={{ paddingRight: "0px", textAlign: "right" }}>
<Chip color={status} label={statuses[status]} size="small" sx={{ borderRadius: 1 }} />
</TableCell>
</TableRow>
))
rows.push(
<Table.Row key="total">
<Table.Cell>
<TableRow key="total">
<TableCell sx={{ fontSize: "12px", paddingLeft: "0px" }}>
<b>Total</b>
</Table.Cell>
<Table.Cell textAlign="right">
<Label size="small" color="black">
{sum(Object.values(statuses))}
</Label>
</Table.Cell>
</Table.Row>,
</TableCell>
<TableCell sx={{ paddingRight: "0px", textAlign: "right" }}>
<Chip color="total" label={sum(Object.values(statuses))} size="small" sx={{ borderRadius: 1 }} />
</TableCell>
</TableRow>,
)
return rows
}
43 changes: 43 additions & 0 deletions components/frontend/src/dashboard/PageHeader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Stack, Typography } from "@mui/material"

import { datePropType, reportPropType } from "../sharedPropTypes"
import { HyperLink } from "../widgets/HyperLink"

export function PageHeader({ lastUpdate, report, reportDate }) {
const reportURL = new URLSearchParams(window.location.search).get("report_url") ?? window.location.href
const title = report?.title ?? "Reports overview"
const changelogURL = `https://quality-time.readthedocs.io/en/v${process.env.REACT_APP_VERSION}/changelog.html`
return (
<Stack
direction="row"
spacing={2}
sx={{ display: "none", displayPrint: "inline-flex", justifyContent: "space-between", width: "100%" }}
>
<Typography key={"reportURL"} data-testid={"reportUrl"}>
<HyperLink url={reportURL}>{title}</HyperLink>
</Typography>
<Typography key={"date"}>{"Report date: " + formatDate(reportDate ?? new Date())}</Typography>
<Typography key={"generated"}>
{"Generated: " + formatDate(lastUpdate) + ", " + formatTime(lastUpdate)}
</Typography>
<Typography key={"version"} data-testid={"version"}>
<HyperLink url={changelogURL}>Quality-time v{process.env.REACT_APP_VERSION}</HyperLink>
</Typography>
</Stack>
)
}
PageHeader.propTypes = {
lastUpdate: datePropType,
report: reportPropType,
reportDate: datePropType,
}

// Hard code en-GB to get European style dates and times. See https://github.com/ICTU/quality-time/issues/8381.

function formatDate(date) {
return date.toLocaleDateString("en-GB", { year: "numeric", month: "2-digit", day: "2-digit" }).replace(/\//g, "-")
}

function formatTime(date) {
return date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { render, screen } from "@testing-library/react"

import { ExportCard } from "./ExportCard"
import { mockGetAnimations } from "./MockAnimations"
import { PageHeader } from "./PageHeader"

beforeEach(() => mockGetAnimations())

@@ -15,6 +15,7 @@ const mockDateOfToday = new Date()

const report = {
report_uuid: "report_uuid",
title: "Title",
subjects: {
subject_uuid: {
type: "subject_type",
@@ -32,37 +33,38 @@ const report = {
},
}

function renderExportCard({ isOverview = false, lastUpdate = new Date(), report = null, reportDate = null } = {}) {
render(<ExportCard isOverview={isOverview} lastUpdate={lastUpdate} report={report} reportDate={reportDate} />)
function renderPageHeader({ lastUpdate = new Date(), report = null, reportDate = null } = {}) {
render(<PageHeader lastUpdate={lastUpdate} report={report} reportDate={reportDate} />)
}

it("displays correct title for an overview report", () => {
renderExportCard({ isOverview: true, report: report })
expect(screen.getByText(/About these reports/)).toBeInTheDocument()
it("displays correct title for the reports overview", () => {
renderPageHeader({})
expect(screen.getByText(/Reports overview/)).toBeInTheDocument()
})

it("displays correct title for a detailed report", () => {
renderExportCard({ report: report })
expect(screen.getByText(/About this report/)).toBeInTheDocument()
it("displays correct title for a report", () => {
window.location.search = "?report_url=https://report/"
renderPageHeader({ report: report })
expect(screen.getByText(/Title/)).toBeInTheDocument()
})

it("displays dates in en-GB format", () => {
renderExportCard({ lastUpdate: mockLastUpdate, report: report, reportDate: mockReportDate })
renderPageHeader({ lastUpdate: mockLastUpdate, report: report, reportDate: mockReportDate })
expect(screen.getByText(/Report date: 24-03-2024/)).toBeInTheDocument()
expect(screen.getByText(/Generated: 26-03-2024, 12:34/)).toBeInTheDocument()
})

it("displays report URL", () => {
renderExportCard({ report: report })
renderPageHeader({ report: report })
expect(screen.getByTestId("reportUrl")).toBeInTheDocument()
})

it("displays version link", () => {
renderExportCard({ lastUpdate: mockLastUpdate, report: report })
renderPageHeader({ lastUpdate: mockLastUpdate, report: report })
expect(screen.getByTestId("version")).toBeInTheDocument()
})

it("displays today as report date if no report date is provided", () => {
renderExportCard({ lastUpdate: mockLastUpdate, report: report })
renderPageHeader({ lastUpdate: mockLastUpdate, report: report })
expect(screen.getByText(`Report date: ${mockDateOfToday}`)).toBeInTheDocument()
})
26 changes: 0 additions & 26 deletions components/frontend/src/errorMessage.js

This file was deleted.

21 changes: 0 additions & 21 deletions components/frontend/src/fields/Comment.js

This file was deleted.

8 changes: 0 additions & 8 deletions components/frontend/src/fields/Comment.test.js

This file was deleted.

23 changes: 23 additions & 0 deletions components/frontend/src/fields/CommentField.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { bool, func, string } from "prop-types"

import { TextField } from "./TextField"

export function CommentField({ disabled, id, onChange, value }) {
return (
<TextField
disabled={disabled}
id={id}
label="Comment"
multiline
onChange={onChange}
placeholder="Enter comments here (HTML allowed; URL's are transformed into links)"
value={value}
/>
)
}
CommentField.propTypes = {
disabled: bool,
id: string,
onChange: func,
value: string,
}
16 changes: 0 additions & 16 deletions components/frontend/src/fields/DateInput.css

This file was deleted.

60 changes: 0 additions & 60 deletions components/frontend/src/fields/DateInput.js

This file was deleted.

69 changes: 0 additions & 69 deletions components/frontend/src/fields/DateInput.test.js

This file was deleted.

54 changes: 0 additions & 54 deletions components/frontend/src/fields/Input.js

This file was deleted.

70 changes: 0 additions & 70 deletions components/frontend/src/fields/Input.test.js

This file was deleted.

102 changes: 0 additions & 102 deletions components/frontend/src/fields/IntegerInput.js

This file was deleted.

121 changes: 0 additions & 121 deletions components/frontend/src/fields/IntegerInput.test.js

This file was deleted.

64 changes: 64 additions & 0 deletions components/frontend/src/fields/MultipleChoiceField.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Autocomplete, TextField } from "@mui/material"
import { arrayOf, bool, element, func, object, oneOfType, string } from "prop-types"

import { stringsPropType } from "../sharedPropTypes"

export function MultipleChoiceField({
disabled,
freeSolo,
helperText,
label,
onChange,
onInputChange,
options,
placeholder,
startAdornment,
value,
}) {
return (
<Autocomplete
value={value}
disabled={disabled}
filterOptions={(x) => x} // Disable built-in filtering
freeSolo={freeSolo} // Allow additional options
fullWidth
multiple
options={options}
onChange={(_event, value) => onChange(value.map((value) => value?.id ?? value))}
onInputChange={onInputChange}
renderInput={(params) => {
return (
<TextField
{...params}
helperText={helperText}
label={label}
placeholder={value.length === 0 ? placeholder : ""}
slotProps={{
input: {
...params.InputProps,
startAdornment: (
<>
{startAdornment}
{params.InputProps.startAdornment}
</>
),
},
}}
/>
)
}}
/>
)
}
MultipleChoiceField.propTypes = {
disabled: bool,
freeSolo: bool,
helperText: string,
label: string,
onChange: func,
onInputChange: func,
options: oneOfType([stringsPropType, arrayOf(object)]),
placeholder: string,
startAdornment: element,
value: stringsPropType,
}
84 changes: 0 additions & 84 deletions components/frontend/src/fields/MultipleChoiceInput.js

This file was deleted.

81 changes: 0 additions & 81 deletions components/frontend/src/fields/MultipleChoiceInput.test.js

This file was deleted.

26 changes: 0 additions & 26 deletions components/frontend/src/fields/PasswordInput.js

This file was deleted.

17 changes: 0 additions & 17 deletions components/frontend/src/fields/PasswordInput.test.js

This file was deleted.

34 changes: 0 additions & 34 deletions components/frontend/src/fields/ReadOnlyInput.js

This file was deleted.

34 changes: 0 additions & 34 deletions components/frontend/src/fields/ReadOnlyInput.test.js

This file was deleted.

70 changes: 0 additions & 70 deletions components/frontend/src/fields/SingleChoiceInput.js

This file was deleted.

107 changes: 0 additions & 107 deletions components/frontend/src/fields/SingleChoiceInput.test.js

This file was deleted.

78 changes: 0 additions & 78 deletions components/frontend/src/fields/StringInput.js

This file was deleted.

95 changes: 0 additions & 95 deletions components/frontend/src/fields/StringInput.test.js

This file was deleted.

99 changes: 99 additions & 0 deletions components/frontend/src/fields/TextField.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { InputAdornment, TextField as MUITextField } from "@mui/material"
import { bool, element, func, number, oneOfType, string } from "prop-types"
import { useState } from "react"

import { childrenPropType } from "../sharedPropTypes"

export function TextField({
children,
disabled,
endAdornment,
error,
helperText,
id,
label,
max,
multiline,
onChange,
placeholder,
required,
select,
startAdornment,
type,
value,
}) {
const [textValue, setTextValue] = useState(value)

function submitIfChanged() {
if (textValue !== value) {
onChange(textValue)
}
}

function onKeyDown(event) {
if (event.key === "Escape") {
setTextValue(value)
}
if (event.key === "Enter") {
submitIfChanged()
}
}

const startInputAdornment = startAdornment ? (
<InputAdornment position="start">{startAdornment}</InputAdornment>
) : null
const endInputAdornment = endAdornment ? <InputAdornment position="end">{endAdornment}</InputAdornment> : null
return (
<MUITextField
defaultValue={textValue ?? ""}
disabled={disabled || (select && children.length === 0)}
error={error}
fullWidth
helperText={helperText}
id={id}
label={label}
maxRows={8}
minRows={3}
multiline={multiline}
onBlur={() => submitIfChanged()}
onChange={select ? (event) => onChange(event.target.value) : (event) => setTextValue(event.target.value)}
onKeyDown={onKeyDown}
onWheel={(event) => event.target.blur()} // Prevent scrolling from changing the number value
placeholder={placeholder}
required={required}
select={select && children.length > 0}
slotProps={{
input: {
endAdornment: endInputAdornment,
inputProps: {
max: max,
min: 0,
},
startAdornment: startInputAdornment,
},
}}
type={type}
variant="outlined"
>
{children}
</MUITextField>
)
}
TextField.propTypes = {
children: childrenPropType,
disabled: bool,
endAdornment: oneOfType([element, string]),
error: bool,
helperText: oneOfType([element, string]),
id: string,
label: oneOfType([element, string]),
max: number,
multiline: bool,
onChange: func,
placeholder: string,
required: bool,
select: bool,
startAdornment: oneOfType([element, string]),
type: string,
value: oneOfType([bool, string]),
}
76 changes: 0 additions & 76 deletions components/frontend/src/fields/TextInput.js

This file was deleted.

76 changes: 0 additions & 76 deletions components/frontend/src/fields/TextInput.test.js

This file was deleted.

11 changes: 0 additions & 11 deletions components/frontend/src/header_footer/Footer.css

This file was deleted.

9 changes: 4 additions & 5 deletions components/frontend/src/header_footer/Footer.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import "./Footer.css"

import BugReportIcon from "@mui/icons-material/BugReport"
import CopyrightIcon from "@mui/icons-material/Copyright"
import FeedbackIcon from "@mui/icons-material/Feedback"
@@ -9,6 +7,7 @@ import MenuBookIcon from "@mui/icons-material/MenuBook"
import PersonIcon from "@mui/icons-material/Person"
import ScienceIcon from "@mui/icons-material/Science"
import {
AppBar,
Container,
Divider,
List,
@@ -145,8 +144,8 @@ function QuoteColumn() {

export function Footer({ lastUpdate, report }) {
return (
<Container maxWidth="100%" sx={{ bgcolor: "black", displayPrint: "none", marginTop: "20px", padding: "60px" }}>
<Container maxWidth="lg">
<AppBar position="relative" sx={{ displayPrint: "none" }}>
<Container maxWidth="lg" sx={{ padding: "60px" }}>
<Stack
direction="row"
sx={{
@@ -162,7 +161,7 @@ export function Footer({ lastUpdate, report }) {
<img alt="" src="./favicon.ico" width="30px" />
</Divider>
</Container>
</Container>
</AppBar>
)
}
Footer.propTypes = {
Original file line number Diff line number Diff line change
@@ -16,7 +16,7 @@ export function HomeButton({ atReportsOverview, openReportsOverview, setSettings
startIcon={<img height="28px" width="28px" src="/favicon.ico" alt={label} />}
sx={{ textTransform: "none" }}
>
<Typography variant="h4">Quality-time</Typography>
<Typography variant="h3">Quality-time</Typography>
</Button>
</span>
</Tooltip>
1 change: 0 additions & 1 deletion components/frontend/src/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import "fomantic-ui-css/semantic.min.css"
import "react-grid-layout/css/styles.css"

import { createRoot } from "react-dom/client"
141 changes: 88 additions & 53 deletions components/frontend/src/issue/IssueStatus.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,36 @@
import { Card, CardActionArea, CardContent, List, ListItem, Tooltip, Typography } from "@mui/material"
import { bool, string } from "prop-types"
import TimeAgo from "react-timeago"

import { Label, Popup } from "../semantic_ui_react_wrappers"
import { issueStatusPropType, metricPropType, settingsPropType, stringsPropType } from "../sharedPropTypes"
import { getMetricIssueIds, ISSUE_STATUS_COLORS } from "../utils"
import { HyperLink } from "../widgets/HyperLink"
import { getMetricIssueIds } from "../utils"
import { TimeAgoWithDate } from "../widgets/TimeAgoWithDate"

function IssueWithoutTracker({ issueId }) {
return (
<Popup
content={
"Please configure an issue tracker by expanding the report title, selecting the 'Issue tracker' tab, and configuring an issue tracker."
<Tooltip
title={
<>
<h4>No issue tracker configured</h4>
<p>
Please configure an issue tracker by expanding the report title, selecting the &lsquo;Issue
tracker&rsquo; tab, and configuring an issue tracker.
</p>
</>
}
header={"No issue tracker configured"}
trigger={<Label color="red">{issueId}</Label>}
/>
>
<span>
<Card elevation={1} sx={{ display: "inline-flex", margin: "1px" }}>
<CardActionArea disableRipple>
<CardContent sx={{ padding: "8px" }}>
<Typography color="error" noWrap>
{issueId} - ?
</Typography>
</CardContent>
</CardActionArea>
</Card>
</span>
</Tooltip>
)
}
IssueWithoutTracker.propTypes = {
@@ -35,41 +50,47 @@ IssuesWithoutTracker.propTypes = {
issueIds: stringsPropType,
}

function labelDetails(issueStatus, settings) {
let details = [<Label.Detail key="name">{issueStatus.name || "?"}</Label.Detail>]
function cardDetails(issueStatus, settings) {
let details = []
if (issueStatus.summary && settings.showIssueSummary.value) {
details.push(<Label.Detail key="summary">{issueStatus.summary}</Label.Detail>)
details.push(<ListItem key="summary">{issueStatus.summary}</ListItem>)
}
if (issueStatus.created && settings.showIssueCreationDate.value) {
details.push(
<Label.Detail key="created">
Created <TimeAgo date={issueStatus.created} />
</Label.Detail>,
<ListItem key="created">
<Typography noWrap variant="inherit">
Created <TimeAgo date={issueStatus.created} />
</Typography>
</ListItem>,
)
}
if (issueStatus.updated && settings.showIssueUpdateDate.value) {
details.push(
<Label.Detail key="updated">
Updated <TimeAgo date={issueStatus.updated} />
</Label.Detail>,
<ListItem key="updated">
<Typography noWrap variant="inherit">
Updated <TimeAgo date={issueStatus.updated} />
</Typography>
</ListItem>,
)
}
if (issueStatus.duedate && settings.showIssueDueDate.value) {
details.push(
<Label.Detail key="duedate">
Due <TimeAgo date={issueStatus.duedate} />
</Label.Detail>,
<ListItem key="duedate">
<Typography noWrap variant="inherit">
Due <TimeAgo date={issueStatus.duedate} />
</Typography>
</ListItem>,
)
}
if (issueStatus.release_name && settings.showIssueRelease.value) {
details.push(releaseLabel(issueStatus))
details.push(release(issueStatus))
}
if (issueStatus.sprint_name && settings.showIssueSprint.value) {
details.push(sprintLabel(issueStatus))
details.push(sprint(issueStatus))
}
return details
return details.length > 0 ? <List dense>{details}</List> : null
}
labelDetails.propTypes = {
cardDetails.propTypes = {
issueStatus: issueStatusPropType,
settings: settingsPropType,
}
@@ -81,31 +102,31 @@ releaseStatus.propTypes = {
issueStatus: issueStatusPropType,
}

function releaseLabel(issueStatus) {
function release(issueStatus) {
const date = issueStatus.release_date ? <TimeAgo date={issueStatus.release_date} /> : null
return (
<Label.Detail key="release">
<ListItem key="release">
{prefixName(issueStatus.release_name, "Release")} {releaseStatus(issueStatus)} {date}
</Label.Detail>
</ListItem>
)
}
releaseLabel.propTypes = {
release.propTypes = {
issueStatus: issueStatusPropType,
}

function sprintLabel(issueStatus) {
function sprint(issueStatus) {
const sprintEnd = issueStatus.sprint_enddate ? (
<>
ends <TimeAgo date={issueStatus.sprint_enddate} />
</>
) : null
return (
<Label.Detail key="sprint">
<ListItem key="sprint">
{prefixName(issueStatus.sprint_name, "Sprint")} ({issueStatus.sprint_state}) {sprintEnd}
</Label.Detail>
</ListItem>
)
}
sprintLabel.propTypes = {
sprint.propTypes = {
issueStatus: issueStatusPropType,
}

@@ -118,26 +139,29 @@ prefixName.propType = {
prefix: string,
}

function issueLabel(issueStatus, settings, error) {
function IssueCard({ issueStatus, settings, error }) {
// The issue status can be unknown when the issue was added recently and the status hasn't been collected yet
const color = error ? "red" : ISSUE_STATUS_COLORS[issueStatus.status_category ?? "unknown"]
const label = (
<Label basic={!error} color={color}>
{issueStatus.issue_id}
{labelDetails(issueStatus, settings)}
</Label>
const color = error ? "error" : (issueStatus.status_category ?? "unknown")
const onClick = issueStatus.landing_url ? () => window.open(issueStatus.landing_url) : null
return (
<Card onClick={onClick} elevation={1} sx={{ display: "inline-flex", margin: "1px" }}>
<CardActionArea disableRipple={!issueStatus.landing_url}>
<CardContent sx={{ padding: "8px" }}>
<Typography color={color} noWrap>
{issueStatus.issue_id} - {issueStatus.name || "?"}
</Typography>
<Typography
component="span" // Default component is p. Use span to prevent "Warning: validateDOMNesting(...): <ul> cannot appear as a descendant of <p>."
variant="body2"
>
{cardDetails(issueStatus, settings)}
</Typography>
</CardContent>
</CardActionArea>
</Card>
)
if (issueStatus.landing_url) {
// Without the span, the popup doesn't work
return (
<span>
<HyperLink url={issueStatus.landing_url}>{label}</HyperLink>
</span>
)
}
return label
}
issueLabel.propTypes = {
IssueCard.propTypes = {
issueStatus: issueStatusPropType,
settings: settingsPropType,
error: string,
@@ -154,15 +178,26 @@ function IssueWithTracker({ issueStatus, settings }) {
popupHeader = "Parse error"
popupContent = "Quality-time could not parse the data received from the issue tracker."
}
let label = issueLabel(issueStatus, settings, popupHeader)
let card = <IssueCard error={popupHeader} issueStatus={issueStatus} settings={settings} />
if (!popupContent && issueStatus.created) {
popupHeader = issueStatus.summary
popupContent = issuePopupContent(issueStatus)
}
if (popupContent) {
label = <Popup header={popupHeader} content={popupContent} flowing hoverable trigger={label} />
card = (
<Tooltip
title={
<>
<h4>{popupHeader}</h4>
{popupContent}
</>
}
>
<span>{card}</span>
</Tooltip>
)
}
return label
return card
}
IssueWithTracker.propTypes = {
issueStatus: issueStatusPropType,
48 changes: 11 additions & 37 deletions components/frontend/src/issue/IssueStatus.test.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { render, screen, waitFor } from "@testing-library/react"
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import history from "history/browser"

import { createTestableSettings } from "../__fixtures__/fixtures"
import { ISSUE_STATUS_COLORS } from "../utils"
import { IssueStatus } from "./IssueStatus"

function renderIssueStatus({
@@ -62,47 +61,22 @@ beforeEach(() => {
})

it("displays the issue id", () => {
const { queryByText } = renderIssueStatus()
expect(queryByText(/123/)).not.toBe(null)
})

it("displays the status", () => {
const { queryByText } = renderIssueStatus()
expect(queryByText(/in progress/)).not.toBe(null)
})

it("displays the status category doing", () => {
renderIssueStatus({ statusCategory: "doing" })
expect(screen.getByText(/123/).className).toContain("blue")
})

it("displays the status category todo", () => {
renderIssueStatus({ statusCategory: "todo" })
expect(screen.getByText(/123/).className).toContain("grey")
})

it("displays the status category done", () => {
renderIssueStatus({ statusCategory: "done" })
expect(screen.getByText(/123/).className).toContain("green")
})

it("displays a missing status category as unknown", () => {
renderIssueStatus()
Object.values(ISSUE_STATUS_COLORS)
.filter((color) => color !== null)
.forEach((color) => {
expect(screen.getByText(/123/).className).not.toContain(color)
})
expect(screen.queryByText(/123/)).not.toBe(null)
})

it("displays the issue landing url", async () => {
it("opens the issue landing url", async () => {
window.open = jest.fn()
const { queryByText } = renderIssueStatus()
expect(queryByText(/123/).closest("a").href).toBe("https://issue/")
fireEvent.click(queryByText(/123/))
expect(window.open).toHaveBeenCalledWith("https://issue")
})

it("does not display an url if the issue has no landing url", async () => {
const { queryByText } = renderIssueStatus({ landingUrl: null })
expect(queryByText(/123/).closest("a")).toBe(null)
it("does not open an url if the issue has no landing url", async () => {
window.open = jest.fn()
const { queryByText } = renderIssueStatus({ landingUrl: "" })
fireEvent.click(queryByText(/123/))
expect(window.open).not.toHaveBeenCalled()
})

it("displays a question mark as status if the issue has no status", () => {
Loading

0 comments on commit 998ea21

Please sign in to comment.