Skip to content

Commit

Permalink
Merge pull request #104 from dave-shawley/scored-components-report
Browse files Browse the repository at this point in the history
Scored components report
  • Loading branch information
in-op authored Aug 28, 2024
2 parents dbf44f6 + c0670d3 commit 5447f8e
Show file tree
Hide file tree
Showing 12 changed files with 183 additions and 70 deletions.
11 changes: 5 additions & 6 deletions public/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -493,8 +493,8 @@ export default {
},
reports: {
available: 'Available Reports',
componentUsage: {
title: 'Component Usage',
components: {
title: 'Components',
columns: {
active_version: {
title: 'Active Version',
Expand Down Expand Up @@ -522,11 +522,10 @@ export default {
description:
'Administrative status for **all** versions of this component. "Forbidden"' +
'or "Deprecated" components always impact the score of project\'s that use any version of the component.'
},
version_count: {
title: 'Version Count',
description: 'Number of versions recorded for this component'
}
},
labels: {
scoredOnly: 'Hide unscored'
}
},
lastUpdated: 'Last Updated: {{lastUpdated}}',
Expand Down
11 changes: 9 additions & 2 deletions src/js/components/Form/Checkbox.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import PropTypes from 'prop-types'
import React from 'react'

function Checkbox({ name, label, onChange, value }) {
function Checkbox({
name,
label,
onChange,
value,
className = 'relative flex items-start'
}) {
return (
<>
<div className="relative flex items-start">
<div className={className}>
<div className="flex h-6 items-center">
<input
id={name}
Expand All @@ -27,6 +33,7 @@ function Checkbox({ name, label, onChange, value }) {
Checkbox.propTypes = {
name: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
className: PropTypes.string,
onChange: PropTypes.func.isRequired,
value: PropTypes.bool.isRequired
}
Expand Down
48 changes: 35 additions & 13 deletions src/js/components/Report/Report.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { Column } from '../../schema'
import { useContext } from 'react'
import { Context } from '../../state'
import { useTranslation } from 'react-i18next'
import { fetchPages, httpGet } from '../../utils'
import { createSingleColumnSorter, fetchPages, httpGet } from '../../utils'
import { Alert } from '../Alert/Alert'
import { Loading } from '../Loading/Loading'
import { ContentArea } from '../ContentArea/ContentArea'
import { NavigableTable, Table } from '../Table'
import { Table } from '../Table'
import { Markdown } from '../Markdown/Markdown'

/**
Expand Down Expand Up @@ -48,10 +48,13 @@ import { Markdown } from '../Markdown/Markdown'
*
* ```
* function MyReport() {
* const [data, setData] = React.useState([])
* return (
* <Report
* endpoint="/reports/my-report"
* keyPrefix="reports.myReport"
* data={data}
* onDataLoaded={setData}
* columns={[
* { name: 'property_name', type: 'text' }
* ]}/>
Expand All @@ -71,6 +74,16 @@ import { Markdown } from '../Markdown/Markdown'
* descriptions
* 5. dispatching `SET_CURRENT_PAGE` to update the breadcrumbs
*
* Note that the client of this component is the owner of the report data.
* This means that you can implement data transformations or filtering as
* you see fit. The `Report` takes care of loading the data from the API
* and displaying it.
*
* @param data {array} -- array of rows to display
* @param onDataLoaded {function} -- hook to invoke when data is updated
* @param onSortChange {function} -- optional hook to invoke when the sort order
* is updated. Is passed a sorting function suitable for `Array.sort`.
* @param children -- optional nodes to display above the table content
* @param columns {ReportColumn} -- array of report columns in the order that
* they appear in the table
* @param endpoint {string} -- Imbi API endpoint to retrieve the report data from
Expand All @@ -84,10 +97,14 @@ import { Markdown } from '../Markdown/Markdown'
* @constructor
*/
function Report({
children,
columns,
endpoint,
keyPrefix,
pageIcon,
data,
onDataLoaded,
onSortChange,
title,
...tableProps
}) {
Expand All @@ -97,7 +114,6 @@ function Report({

const [errorMessage, setErrorMessage] = useState(null)
const [fetched, setFetched] = useState(false)
const [reportData, setReportData] = useState([])
const [sort, setSort] = useState(['', null])

const pageTitle = title || t('title')
Expand All @@ -118,7 +134,7 @@ function Report({
endpoint,
state,
(data, isComplete) => {
setReportData((prevState) => prevState.concat(data))
onDataLoaded((prevState) => prevState.concat(data))
if (isComplete) {
setFetched(true)
}
Expand All @@ -133,14 +149,12 @@ function Report({
function onSortDirection(column, direction) {
const [curCol, curDir] = sort
if (curCol !== column || curDir !== direction) {
setReportData(
[...reportData].sort((a, b) => {
if (a[column] === null || a[column] < b[column])
return direction === 'asc' ? -1 : 1
if (b[column] === null || b[column] < a[column])
return direction === 'asc' ? 1 : -1
})
)
const sorter = createSingleColumnSorter(column, direction)
if (onSortChange) {
onSortChange(sorter)
} else {
onDataLoaded([...data].sort(sorter))
}
setSort([column, direction])
}
}
Expand All @@ -165,7 +179,8 @@ function Report({
pageTitle={title || t(`title`)}
className="flex-grow"
pageIcon={pageIcon}>
<Table {...tableProps} data={reportData} columns={augmentedColumns} />
{children}
<Table {...tableProps} data={data} columns={augmentedColumns} />
<div className="italic text-gray-600 text-right text-xs">
{globalT('reports.lastUpdated', { lastUpdated: new Date().toString() })}
</div>
Expand Down Expand Up @@ -207,9 +222,16 @@ const TableProps = Object.fromEntries(
)
)
Report.propTypes = {
children: PropTypes.oneOfType([
PropTypes.node,
PropTypes.arrayOf(PropTypes.node)
]),
columns: PropTypes.arrayOf(PropTypes.exact(ReportColumn)).isRequired,
data: PropTypes.array.isRequired,
endpoint: PropTypes.string.isRequired,
keyPrefix: PropTypes.string.isRequired,
onDataLoaded: PropTypes.func.isRequired,
onSortChange: PropTypes.func,
pageIcon: PropTypes.string,
title: PropTypes.string,
...TableProps
Expand Down
3 changes: 2 additions & 1 deletion src/js/components/Table/NavigableTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Table } from './Table'
import { useSearchParams } from 'react-router-dom'
import { SlideOver } from '../SlideOver/SlideOver'
import { Icon } from '../Icon/Icon'
import { createSingleColumnSorter } from '../../utils'

/**
* Table that shows a slide over when a row is clicked
Expand Down Expand Up @@ -78,8 +79,8 @@ function NavigableTable({
function onSortDirection(column, direction) {
const [curCol, curDir] = sort
if (curCol !== column || curDir !== direction) {
onSortChange(createSingleColumnSorter(column, direction))
setSort([column, direction])
onSortChange(column, direction)
}
}

Expand Down
19 changes: 19 additions & 0 deletions src/js/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,22 @@ export function camelCase(s) {
.toLowerCase()
.replace(/(_[a-z])/g, (group) => group.toUpperCase().replace('_', ''))
}

/**
* Create a function suitable for use with Array.sort
*
* The function will sort objects into the selected `direction` using `column` as the sort key
*
* @param column name of the property to sort rows by
* @param direction 'asc' or 'desc'
* @returns {function(a:number, b:number): number}
*/
export function createSingleColumnSorter(column, direction) {
return (a, b) => {
if (a[column] === null || a[column] < b[column])
return direction === 'asc' ? -1 : 1
if (b[column] === null || b[column] < a[column])
return direction === 'asc' ? 1 : -1
return 0
}
}
8 changes: 2 additions & 6 deletions src/js/views/Main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { NewEntry, OperationsLog } from './OperationsLog/'
import { Project } from './Project/'
import { Projects } from './Projects/'
import {
ComponentUsage,
Components,
NamespaceKPIs,
OutdatedProjects,
ProjectTypeDefinitions,
Expand All @@ -21,7 +21,6 @@ import { UserProfile, UserSettings } from './User'

import { useSettings } from '../settings'
import { User as UserSchema } from '../schema'
import { Components } from './Admin/Components'

function Main({ user }) {
const [globalState] = useContext(Context)
Expand Down Expand Up @@ -70,10 +69,7 @@ function Main({ user }) {
/>
<Route path="/ui/projects" element={<Projects user={user} />} />
<Route path="/ui/reports" element={<Reports />} />
<Route
path="/ui/reports/component-usage"
element={<ComponentUsage />}
/>
<Route path="/ui/reports/components" element={<Components />} />
<Route
path="/ui/reports/namespace-kpis"
element={<NamespaceKPIs />}
Expand Down
11 changes: 2 additions & 9 deletions src/js/views/Project/Components/ComponentList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,8 @@ function ComponentList({ project, urlPath }) {
)
}, [])

function onSortChange(column, direction) {
setComponents(
[...components].sort((a, b) => {
if (a[column] == null || a[column] < b[column])
return direction === 'asc' ? -1 : 1
if (b[column] === null || b[column] < a[column])
return direction === 'asc' ? 1 : -1
})
)
function onSortChange(sortFunction) {
setComponents((prevState) => [...prevState].sort(sortFunction))
}

if (fetching) return <Loading />
Expand Down
27 changes: 0 additions & 27 deletions src/js/views/Reports/ComponentUsage.jsx

This file was deleted.

99 changes: 99 additions & 0 deletions src/js/views/Reports/Components.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import React, { useEffect, useState } from 'react'
import Report from '../../components/Report/Report'
import { useTranslation } from 'react-i18next'
import { Checkbox } from '../../components/Form/Checkbox'

function createColumn(name, type, headerClassName, className) {
const tableOptions = { headerClassName, className }
return { name, type, tableOptions }
}

function Components() {
const { t } = useTranslation()
const packageSearchURL = ({ package_url }) => {
const params = new URLSearchParams()
params.append('f', `component_versions:"${package_url}"`)
params.append('s', JSON.stringify({ name: 'asc' }))
return `/ui/projects?${params.toString()}`
}
const [scoredOnly, setScoredOnly] = useState(false)
const [filterText, setFilterText] = useState('')

const [reportData, setReportData] = useState([])
const [displayedData, setDisplayedData] = useState([])
useEffect(() => {
let filteredRows = reportData
if (scoredOnly) {
filteredRows = filteredRows.filter(
(row) => row.status !== 'Active' || row.active_version !== null
)
}
if (filterText.length > 0) {
const filterColumn = filterText.startsWith('pkg:')
? 'package_url'
: 'name'
filteredRows = filteredRows.filter((row) =>
row[filterColumn].toLowerCase().includes(filterText.toLowerCase())
)
}
setDisplayedData(filteredRows)
}, [filterText, reportData, scoredOnly])

function onFilterChange(event) {
const value = event.target.value
setFilterText(value)
}

return (
<Report
endpoint="/reports/component-usage"
keyPrefix="reports.components"
pageIcon="fas cubes"
data={displayedData}
rowURL={packageSearchURL}
onDataLoaded={setReportData}
onSortChange={(sortFunc) => {
const nextData = [...reportData]
setReportData(nextData.sort(sortFunc))
}}
columns={[
createColumn('name', 'text', 'w-3/12', 'truncate'),
createColumn('package_url', 'text', 'w-4/12', 'truncate'),
createColumn('status', 'text', 'w-1/12', 'overflow-clip'),
createColumn(
'active_version',
'text',
'w-2/12 text-center',
'overflow-clip text-center'
),
createColumn(
'project_count',
'number',
'w-2/12 text-center',
'text-center'
)
]}>
<div className="relative flex items-stretch rounded-md flex-grow focus-within:z-10 gap-1">
<input
autoFocus={true}
className="w-11/12 rounded-md shadow-sm pl-10 text-sm border-gray-300 focus:border-gray-300 focus:outline-0 focus:ring-0"
onChange={onFilterChange}
type="text"
autoComplete="off"
placeholder={t('common.filter')}
style={{ padding: '.575rem' }}
value={filterText}
/>
<Checkbox
name="scoredOnly"
className="relative flex items-center flex-shrink-0 pl-2"
onChange={(_, value) => setScoredOnly(value)}
label={t('reports.components.labels.scoredOnly')}
value={scoredOnly}
/>
</div>
</Report>
)
}

export { Components }
Loading

0 comments on commit 5447f8e

Please sign in to comment.