diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 50c8ed54..9a4f2b5d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ repos: - id: debug-statements - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.8 + rev: v0.5.5 hooks: - id: ruff args: @@ -31,7 +31,7 @@ repos: ## ES - repo: https://github.com/pre-commit/mirrors-eslint - rev: v9.4.0 + rev: v9.8.0 hooks: - id: eslint additional_dependencies: diff --git a/backend/ibutsu_server/controllers/widget_config_controller.py b/backend/ibutsu_server/controllers/widget_config_controller.py index 53a3c1ff..48e19374 100644 --- a/backend/ibutsu_server/controllers/widget_config_controller.py +++ b/backend/ibutsu_server/controllers/widget_config_controller.py @@ -11,6 +11,8 @@ from ibutsu_server.util.query import get_offset from ibutsu_server.util.uuid import validate_uuid +# TODO: pydantic validation of request data structure + def add_widget_config(widget_config=None, token_info=None, user=None): """Create a new widget config @@ -25,6 +27,7 @@ def add_widget_config(widget_config=None, token_info=None, user=None): data = connexion.request.json if data["widget"] not in WIDGET_TYPES.keys(): return "Bad request, widget type does not exist", HTTPStatus.BAD_REQUEST + # add default weight of 10 if not data.get("weight"): data["weight"] = 10 diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 00000000..74099e72 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,8 @@ +// eslint.config.js +export default [ + { + rules: { + "no-unused-vars": "warn", // this isn't actually working through pre-commit or webpack + } + } +]; diff --git a/frontend/package.json b/frontend/package.json index e055af62..138b8c73 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,9 +6,11 @@ "@babel/core": "^7.24.7", "@babel/eslint-parser": "^7.24.7", "@babel/helper-call-delegate": "^7.12.13", + "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/plugin-syntax-jsx": "^7.24.7", "@babel/plugin-transform-class-properties": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/preset-flow": "^7.24.7", "@babel/preset-react": "^7.24.7", "@greatsumini/react-facebook-login": "^3.3.3", @@ -44,6 +46,9 @@ "typescript": "^4.9.5", "wolfy87-eventemitter": "^5.2.9" }, + "devDependencies": { + "@babel/plugin-proposal-private-property-in-object": "^7.21.11" + }, "scripts": { "start": "serve -s build -l tcp://0.0.0.0:8080", "build": "./bin/write-version-file.js && react-scripts build", diff --git a/frontend/src/admin.js b/frontend/src/admin.js index fadabc17..9eda2f35 100644 --- a/frontend/src/admin.js +++ b/frontend/src/admin.js @@ -1,22 +1,18 @@ import React from 'react'; -import { - Nav, - NavList -} from '@patternfly/react-core'; - -import { NavLink, Route, Routes } from 'react-router-dom'; +import { Navigate, Route, Routes } from 'react-router-dom'; import EventEmitter from 'wolfy87-eventemitter'; import ElementWrapper from './components/elementWrapper'; -import { IbutsuPage } from './components'; -import { AdminHome } from './pages/admin/home'; +import AdminHome from './pages/admin/home'; import { UserList } from './pages/admin/user-list'; import { UserEdit } from './pages/admin/user-edit'; import { ProjectList } from './pages/admin/project-list'; import { ProjectEdit } from './pages/admin/project-edit'; import { AuthService } from './services/auth'; + import './app.css'; +import AdminPage from './components/admin-page'; export class Admin extends React.Component { @@ -34,34 +30,20 @@ export class Admin extends React.Component { } render() { - const navigation = ( - - ); - return ( - - - - }/> - } /> - } /> - } /> - } /> - - - + + } + > + } /> + } /> + } /> + } /> + } /> + + }/> + ); } } diff --git a/frontend/src/app.js b/frontend/src/app.js index 13bf29ef..a2368745 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -1,13 +1,9 @@ import React from 'react'; -import { - Nav, - NavList -} from '@patternfly/react-core'; import EventEmitter from 'wolfy87-eventemitter'; import ElementWrapper from './components/elementWrapper'; -import { NavLink, Route, Routes } from 'react-router-dom'; +import { Route, Routes } from 'react-router-dom'; import { Dashboard } from './dashboard'; import { ReportBuilder } from './report-builder'; @@ -15,14 +11,13 @@ import { RunList } from './run-list'; import { Run } from './run'; import { ResultList } from './result-list'; import { Result } from './result'; -import { Settings } from './settings'; import { View, IbutsuPage } from './components'; -import { HttpClient } from './services/http'; -import { getActiveProject } from './utilities'; -import './app.css'; +import { IbutsuContext } from './services/context'; +import './app.css'; export class App extends React.Component { + static contextType = IbutsuContext; constructor(props) { super(props); this.eventEmitter = new EventEmitter(); @@ -33,79 +28,66 @@ export class App extends React.Component { searchValue: '', views: [] }; - this.eventEmitter.on('projectChange', () => { - this.getViews(); - }); - } - - getViews() { - let params = {'filter': ['type=view', 'navigable=true']}; - let project = getActiveProject(); - if (project) { - params['filter'].push('project_id=' + project.id); - } - HttpClient.get([Settings.serverUrl, 'widget-config'], params) - .then(response => HttpClient.handleResponse(response)) - .then(data => { - data.widgets.forEach(widget => { - if (project) { - widget.params['project'] = project.id; - } - else { - delete widget.params['project']; - } - }); - this.setState({views: data.widgets}); - }); - } - - componentDidMount() { - this.getViews(); } render() { document.title = 'Ibutsu'; - const { views } = this.state; - const navigation = ( - - ); - return ( - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + + } + /> + } + > + + {/* Nested project routes */} + + } + /> + } + /> + + + + } + /> + } + /> + + } + /> + } + /> + + } + /> + + } + /> + + + ); } } diff --git a/frontend/src/base.js b/frontend/src/base.js index 0c27cd18..9e51833a 100644 --- a/frontend/src/base.js +++ b/frontend/src/base.js @@ -3,35 +3,39 @@ import React from 'react'; import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom'; import { App } from './app'; import { Admin } from './admin'; -import { Profile } from './profile'; +import Profile from './profile'; import { Login } from './login'; import { SignUp } from './sign-up'; import { ForgotPassword } from './forgot-password'; import { ResetPassword } from './reset-password'; import { AuthService } from './services/auth'; import ElementWrapper from './components/elementWrapper'; +import { IbutsuContextProvider } from './services/context'; export const Base = () => { return ( - - - } /> - } /> - } /> - } /> - : } - /> - : } - /> - : } - /> - - + + + + } /> + } /> + } /> + } /> + : } + /> + : } + /> + : } + /> + } /> + + + ); }; diff --git a/frontend/src/components/FileUpload.spec.cy.js b/frontend/src/components/FileUpload.spec.cy.js index e51edf17..103dee13 100644 --- a/frontend/src/components/FileUpload.spec.cy.js +++ b/frontend/src/components/FileUpload.spec.cy.js @@ -2,6 +2,7 @@ import { mount } from 'cypress/react'; import React from 'react'; import { FileUpload } from '.'; +import { IbutsuContext } from '../services/context'; describe('FileUpload', () => { @@ -10,16 +11,13 @@ describe('FileUpload', () => { cy.get('button'); }); - it('should run the onClick method via onUploadClick', () => { - const onClick = cy.spy().as('uploadSpy'); - mount(Upload); - cy.get('button').click(); - cy.get('@uploadSpy').should('have.been.calledOnce'); - }); - it('should fire the beforeUpload when a file is changed', () => { const beforeUpload = cy.spy().as('buSpy'); - mount(Upload); + mount( + + Upload + + ); cy.get('input[type="file"]') .selectFile({ contents: 'cypress/fixtures/example.json', @@ -30,7 +28,11 @@ describe('FileUpload', () => { it('should upload the file and trigger the afterUpload event', () => { const afterUpload = cy.spy().as('auSpy'); - mount(Upload); + mount( + + Upload + + ); cy.get('input[type="file"]') .selectFile({ contents: 'cypress/fixtures/example.json', diff --git a/frontend/src/components/admin-page.js b/frontend/src/components/admin-page.js new file mode 100644 index 00000000..c3232a1b --- /dev/null +++ b/frontend/src/components/admin-page.js @@ -0,0 +1,64 @@ +import React from 'react'; + +import { + Nav, + NavList, + Page, + PageSidebar, + PageSidebarBody +} from '@patternfly/react-core'; + +import { Link, Outlet } from 'react-router-dom'; +import ElementWrapper from './elementWrapper'; + +import { IbutsuHeader } from './ibutsu-header'; +import PropTypes from 'prop-types'; + + + +const AdminPage = (props) => { + // TODO useEffect instead of eventEmitter prop + // TODO notifications on admin page with state and AlertGroup + const navigation = ( + // TODO what is onNavSelect doing here ... I just carried this from a class ref + + + + + + ); + + document.title = 'Administration | Ibutsu'; + + return ( + + } + sidebar={navigation} + isManagedSidebar={true} + style={{position: "relative"}} + > + + + + ); +}; + +AdminPage.propTypes = { + eventEmitter: PropTypes.object, +}; + +export default AdminPage; diff --git a/frontend/src/components/elementWrapper.js b/frontend/src/components/elementWrapper.js index 0d43e226..32c040ff 100644 --- a/frontend/src/components/elementWrapper.js +++ b/frontend/src/components/elementWrapper.js @@ -11,7 +11,7 @@ const ElementWrapper = (props) => { const Element = props.routeElement; const eventEmitter = props.eventEmitter - return ; + return ; }; ElementWrapper.propTypes = { diff --git a/frontend/src/components/fileupload.js b/frontend/src/components/fileupload.js index 89ad2c22..bd623545 100644 --- a/frontend/src/components/fileupload.js +++ b/frontend/src/components/fileupload.js @@ -2,19 +2,21 @@ import React from 'react'; import PropTypes from 'prop-types'; import { HttpClient } from '../services/http'; +import { IbutsuContext } from '../services/context'; +import { Button, Tooltip } from '@patternfly/react-core'; export class FileUpload extends React.Component { + // TODO: refactor to functional + // TODO: Consider explicit project selection for upload instead of inferred from context + static contextType = IbutsuContext; static propTypes = { url: PropTypes.string.isRequired, name: PropTypes.string, - params: PropTypes.object, multiple: PropTypes.bool, - onClick: PropTypes.func, beforeUpload: PropTypes.func, afterUpload: PropTypes.func, children: PropTypes.node, className: PropTypes.node, - component: PropTypes.node, isUnstyled: PropTypes.bool, } @@ -24,17 +26,13 @@ export class FileUpload extends React.Component { url: props.url, name: props.name ? props.name : 'file', multiple: !!props.multiple, - onClick: props.onClick ? props.onClick : null, beforeUpload: props.beforeUpload ? props.beforeUpload : null, afterUpload: props.afterUpload ? props.afterUpload : null }; this.inputRef = React.createRef(); } - onUploadClick = (e) => { - if (this.state.onClick) { - this.state.onClick(e); - } + onClick = () => { this.inputRef.current.click(); } @@ -52,8 +50,13 @@ export class FileUpload extends React.Component { uploadFile = (file) => { const files = {}; + const { primaryObject } = this.context; files[this.state.name] = file; - HttpClient.upload(this.state.url, files, this.props.params).then((response) => { + HttpClient.upload( + this.state.url, + files, + {'project': primaryObject?.id} + ).then((response) => { response = HttpClient.handleResponse(response, 'response'); if (this.state.afterUpload) { this.state.afterUpload(response); @@ -63,14 +66,15 @@ export class FileUpload extends React.Component { render() { const { children, className } = this.props; - const Component = this.props.component || 'button'; - const styles = this.props.isUnstyled ? {} : {cursor: 'pointer', display: 'inline', padding: '0', margin: '0'}; + const { primaryObject } = this.context; return ( - - {children} - + + + ); } diff --git a/frontend/src/components/filtertable.js b/frontend/src/components/filtertable.js index a411a5b4..7c727618 100644 --- a/frontend/src/components/filtertable.js +++ b/frontend/src/components/filtertable.js @@ -24,9 +24,10 @@ import { import { Settings } from '../settings'; import { HttpClient } from '../services/http'; -import { getActiveProject, toAPIFilter } from '../utilities'; +import { toAPIFilter } from '../utilities'; import { TableEmptyState, TableErrorState } from './tablestates'; +import { IbutsuContext } from '../services/context'; export class FilterTable extends React.Component { static propTypes = { @@ -173,6 +174,7 @@ export class FilterTable extends React.Component { // TODO Extend this to contain the filter handling functions, and better integrate filter state // with FilterTable. See https://github.com/ibutsu/ibutsu-server/issues/230 export class MetaFilter extends React.Component { + static contextType = IbutsuContext; static propTypes = { runId: PropTypes.string, setFilter: PropTypes.func, @@ -257,8 +259,8 @@ export class MetaFilter extends React.Component { let api_filter = toAPIFilter(customFilters).join(); console.debug('APIFILTER: ' + customFilters); - let project = getActiveProject(); - let projectId = project ? project.id : '' + const { primaryObject } = this.context; + let projectId = primaryObject ? primaryObject.id : '' // make runId optional let params = {} @@ -290,8 +292,8 @@ export class MetaFilter extends React.Component { } getProjectFilterParams() { - let project = getActiveProject(); - HttpClient.get([Settings.serverUrl, 'project', 'filter-params', project.id]) + const { primaryObject } = this.context; + HttpClient.get([Settings.serverUrl, 'project', 'filter-params', primaryObject.id]) .then(response => HttpClient.handleResponse(response)) .then(data => { this.setState({fieldOptions: data}); diff --git a/frontend/src/components/ibutsu-header.js b/frontend/src/components/ibutsu-header.js index 0e4d6db6..e08f1ffc 100644 --- a/frontend/src/components/ibutsu-header.js +++ b/frontend/src/components/ibutsu-header.js @@ -34,40 +34,87 @@ import { import { BarsIcon, MoonIcon, ServerIcon, TimesIcon, QuestionCircleIcon, UploadIcon } from '@patternfly/react-icons'; import { FileUpload, UserDropdown } from '../components'; -import { MONITOR_UPLOAD_TIMEOUT } from '../constants'; +import { MONITOR_UPLOAD_TIMEOUT, VERSION_CHECK_TIMEOUT } from '../constants'; +import packageJson from '../../package.json' import { HttpClient } from '../services/http'; import { Settings } from '../settings'; -import { getActiveProject, getTheme, setTheme } from '../utilities'; +import { getDateString, getTheme, setTheme } from '../utilities'; +import { IbutsuContext } from '../services/context'; export class IbutsuHeader extends React.Component { + // TODO: convert to functional + static contextType = IbutsuContext; static propTypes = { eventEmitter: PropTypes.object, navigate: PropTypes.func, - version: PropTypes.string + version: PropTypes.string, + params: PropTypes.object, } constructor(props) { super(props); - let project = getActiveProject(); this.eventEmitter = props.eventEmitter; + this.versionCheckId = ''; this.state = { + // version + version: packageJson.version, + // upload state uploadFileName: '', importId: '', monitorUploadId: null, - isAboutOpen: false, + // project state isProjectSelectorOpen: false, - selectedProject: project || '', - inputValue: project?.title || '', + selectedProject: '', + inputValue: '', filterValue: '', projects: [], filteredProjects: [], + // misc + isAboutOpen: false, isDarkTheme: getTheme() === 'dark', - version: props.version }; } + sync_context = () => { + // Primary object + const { primaryObject, setPrimaryObject, setPrimaryType } = this.context; + const { selectedProject } = this.state; + const paramProject = this.props.params?.project_id; + let updatedPrimary = undefined; + + // API fetch and set the context + if (paramProject && primaryObject?.id !== paramProject) { + HttpClient.get([Settings.serverUrl, 'project', paramProject]) + .then(response => HttpClient.handleResponse(response)) + .then(data => { + updatedPrimary = data; + setPrimaryObject(data) + setPrimaryType('project') + // update state + this.setState({ + selectedProject: data, + isProjectSelectorOpen: false, + inputValue: data?.title, + filterValue: '' + }); + }); + } + + // update selector state + if (updatedPrimary && !selectedProject) { + this.setState({ + selectedProject: updatedPrimary, + inputValue: updatedPrimary.title + }) + } + + if ( updatedPrimary ) { + this.emitProjectChange(updatedPrimary); + } + } + showNotification(type, title, message, action = null, timeout = null, key = null) { if (!this.eventEmitter) { return; @@ -75,30 +122,52 @@ export class IbutsuHeader extends React.Component { this.eventEmitter.emit('showNotification', type, title, message, action, timeout, key); } - emitProjectChange() { - if (!this.eventEmitter) { - return; - } - this.eventEmitter.emit('projectChange'); + checkVersion() { + const frontendUrl = window.location.origin; + HttpClient.get([frontendUrl, 'version.json'], {'v': getDateString()}) + .then(response => HttpClient.handleResponse(response)) + .then((data) => { + if (data && data.version && (data.version !== this.state.version)) { + const action = { window.location.reload(); }}>Reload; + this.showNotification( + 'info', + 'Ibutsu has been updated', + 'A newer version of Ibutsu is available, click reload to get it.', + action, + true, + 'check-version'); + } + }); } - emitThemeChange() { + emitProjectChange(value = null) { if (!this.eventEmitter) { return; } - this.eventEmitter.emit('themeChange'); + this.eventEmitter.emit('projectChange', value); } - getProjects() { - const params = {pageSize: 20}; // TODO this isn't a problem, until it is + getSelectorOptions = (endpoint = "project") => { + // adding s here seems dumb, but this scope is small, it's only abstracted for 2 things + // TODO: iterate over pages, fix controller filtering behavior to apply pageSize AFTER filter + const pluralEndpoint = endpoint+'s'; + const params = {pageSize: 20}; if (this.state.filterValue) { params['filter'] = ['title%' + this.state.filterValue]; } - HttpClient.get([Settings.serverUrl, 'project'], params) + HttpClient.get([Settings.serverUrl, endpoint], params) .then(response => HttpClient.handleResponse(response)) - .then(data => this.setState({projects: data['projects'], filteredProjects: data['projects']})); + .then(data => { + this.setState( + { + projects: data[pluralEndpoint], + filteredProjects: data[pluralEndpoint], + }) + } + ); } + // TODO: separate functional upload component from ibutsu-header onBeforeUpload = (files) => { for (var i = 0; i < files.length; i++) { this.showNotification('info', 'File Uploaded', files[i].name + ' has been uploaded, importing will start momentarily.'); @@ -122,6 +191,7 @@ export class IbutsuHeader extends React.Component { } monitorUpload = () => { + const { primaryObject } = this.context; HttpClient.get([Settings.serverUrl, 'import', this.state.importId]) .then(response => HttpClient.handleResponse(response)) .then(data => { @@ -131,7 +201,7 @@ export class IbutsuHeader extends React.Component { let action = null; if (data.metadata.run_id) { const RunButton = () => ( - {this.props.navigate('/runs/' + data.metadata.run_id)}}> + {this.props.navigate('/project/' + (data.metadata.project_id || primaryObject.id) + '/runs/' + data.metadata.run_id)}}> Go to Run ) @@ -144,11 +214,11 @@ export class IbutsuHeader extends React.Component { onProjectToggle = () => { this.setState({isProjectSelectorOpen: !this.state.isProjectSelectorOpen}); - }; + } onProjectSelect = (_event, value) => { - const activeProject = getActiveProject(); - if (activeProject && activeProject.id === value.id) { + const { primaryObject, setPrimaryObject, setPrimaryType } = this.context; + if (primaryObject?.id === value?.id) { this.setState({ isProjectSelectorOpen: false, inputValue: value.title, @@ -156,53 +226,69 @@ export class IbutsuHeader extends React.Component { }); return; } - - const project = JSON.stringify(value); - localStorage.setItem('project', project); + // update context + setPrimaryObject(value) + setPrimaryType('project') + // update state this.setState({ selectedProject: value, isProjectSelectorOpen: false, - inputValue: value.title, + inputValue: value?.title, filterValue: '' }); - this.emitProjectChange(); - }; + // Consider whether the location should be changed within the emit hooks? + this.props.navigate('/project/' + value?.id + '/dashboard/' + value?.default_dashboard_id); + + // useEffect with dependency on functional component to remove passing value, handlers don't see updated context + this.emitProjectChange(value); + } onProjectClear = () => { - localStorage.removeItem('project'); + const { setPrimaryObject } = this.context; + this.setState({ selectedProject: '', isProjectSelectorOpen: false, inputValue: '', filterValue: '' - }, this.getProjects); + }); + setPrimaryObject(); + + this.props.navigate("/project"); + this.emitProjectChange(); } - onTextInputChange = (_event, value) => { + onProjectTextInputChange = (_event, value) => { this.setState({ inputValue: value, filterValue: value - }, this.getProjects); - }; + }, this.getSelectorOptions('project')); + } toggleAbout = () => { this.setState({isAboutOpen: !this.state.isAboutOpen}); - }; + } onThemeChanged = (isChecked) => { setTheme(isChecked ? 'dark' : 'light'); - this.setState({isDarkTheme: isChecked}, this.emitThemeChange); + this.setState({isDarkTheme: isChecked}); } componentWillUnmount() { if (this.state.monitorUploadId) { clearInterval(this.state.monitorUploadId); } + if (this.versionCheckId) { + clearInterval(this.versionCheckId); + } } componentDidMount() { - this.getProjects(); + this.getSelectorOptions("project"); + this.sync_context(); + this.checkVersion(); + this.versionCheckId = setInterval(() => this.checkVersion(), VERSION_CHECK_TIMEOUT); } componentDidUpdate(prevProps, prevState) { @@ -238,7 +324,7 @@ export class IbutsuHeader extends React.Component { @@ -336,16 +418,37 @@ export class IbutsuHeader extends React.Component { }} > - - See all results + See all results diff --git a/frontend/src/services/context.js b/frontend/src/services/context.js new file mode 100644 index 00000000..b5153787 --- /dev/null +++ b/frontend/src/services/context.js @@ -0,0 +1,32 @@ +import React from 'react'; +import {createContext, useState} from 'react'; +import PropTypes from 'prop-types'; + + +const IbutsuContext = createContext({primaryType: 'project'}); + +const IbutsuContextProvider = (props) => { + const [primaryType, setPrimaryType] = useState(); + const [primaryObject, setPrimaryObject] = useState(); + const [activeDashboard, setActiveDashboard] = useState(); + + return ( + + {props.children} + + ); +} + +IbutsuContextProvider.propTypes = { + children: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), +} + +export {IbutsuContext, IbutsuContextProvider}; diff --git a/frontend/src/utilities.js b/frontend/src/utilities.js index 453e4750..82265aa6 100644 --- a/frontend/src/utilities.js +++ b/frontend/src/utilities.js @@ -28,10 +28,10 @@ import { NUMERIC_OPERATIONS, NUMERIC_RESULT_FIELDS, NUMERIC_RUN_FIELDS, + THEME_KEY, } from './constants'; import { ClassificationDropdown } from './components'; - export function getDateString() { return String((new Date()).getTime()); } @@ -200,14 +200,14 @@ export function resultToRow(result, filterFunc) { } } if (result.metadata && result.metadata.run) { - runLink = {result.run_id}; + runLink = {result.run_id}; } if (result.metadata && result.metadata.classification) { classification = {result.metadata.classification.split('_')[0]}; } return { "cells": [ - {title: {result.test_id} {markers}}, + {title: {result.test_id} {markers}}, {title: runLink}, {title: {resultIcon} {toTitleCase(result.result)} {classification}}, {title: round(result.duration) + 's'}, @@ -247,7 +247,7 @@ export function resultToClassificationRow(result, index, filterFunc) { "isOpen": false, "result": result, "cells": [ - {title: {result.test_id} {markers}}, + {title: {result.test_id} {markers}}, {title: {resultIcon} {toTitleCase(result.result)}}, {title: {exceptionBadge}}, {title: }, @@ -282,7 +282,7 @@ export function resultToComparisonRow(result, index) { } let cells = [] - cells.push({title: {result[0].test_id} {markers}}); + cells.push({title: {result[0].test_id} {markers}}); result.forEach((result, index) => { cells.push({title: {resultIcons[index]} {toTitleCase(result.result)}}); }); @@ -401,30 +401,6 @@ export function getOperationsFromField(field) { return operations; } -export function getActiveProject() { - let project = localStorage.getItem('project'); - if (project) { - project = JSON.parse(project); - } - return project; -} - -export function clearActiveProject() { - localStorage.removeItem('project'); -} - -export function getActiveDashboard() { - let dashboard = localStorage.getItem('dashboard'); - if (dashboard) { - dashboard = JSON.parse(dashboard); - } - return dashboard; -} - -export function clearActiveDashboard() { - localStorage.removeItem('dashboard'); -} - export function projectToOption(project) { if (!project) { return ''; @@ -528,9 +504,31 @@ export function debounce(func, timeout = 500) { } export function getTheme() { - return localStorage.getItem('theme'); + // check local storage and default to browser theme + const local_theme = localStorage.getItem(THEME_KEY) + if (local_theme) { + return local_theme; + } + else { + let browser_preference = window.matchMedia('(prefers-color-scheme: dark)'); + return browser_preference.matches ? 'dark' : 'light'; + } } export function setTheme(theme) { - localStorage.setItem('theme', theme); + let target_theme = theme ? theme : getTheme(); + if (!['dark', 'light'].includes(target_theme)) { + console.log('bad theme value passed, defaulting to dark'); + target_theme = 'dark'; + } + + localStorage.setItem('theme', target_theme); + if (target_theme === 'dark') { + console.log('setting dark theme'); + document.firstElementChild.classList.add('pf-v5-theme-dark'); + } + else { + console.log('setting light theme'); + document.firstElementChild.classList.remove('pf-v5-theme-dark'); + } } diff --git a/frontend/src/views/accessibilityanalysis.js b/frontend/src/views/accessibilityanalysis.js index bb94ab7a..8ae3db91 100644 --- a/frontend/src/views/accessibilityanalysis.js +++ b/frontend/src/views/accessibilityanalysis.js @@ -32,13 +32,13 @@ import { Settings } from '../settings'; import { JSONTree } from 'react-json-tree'; import Editor from '@monaco-editor/react'; import { - getActiveProject, parseFilter, getSpinnerRow, resultToRow, } from '../utilities'; import { GenericAreaWidget } from '../widgets'; import { FilterTable, TabTitle } from '../components'; +import { IbutsuContext } from '../services/context'; const MockRun = { id: null, duration: null, @@ -54,6 +54,7 @@ const MockRun = { export class AccessibilityAnalysisView extends React.Component { + static contextType = IbutsuContext; static propTypes = { location: PropTypes.object, navigate: PropTypes.func, @@ -119,16 +120,16 @@ export class AccessibilityAnalysisView extends React.Component { return; } let params = this.props.view.params; - let project = getActiveProject(); - if (project) { - params['project'] = project.id; + const { primaryObject } = this.context; + if (primaryObject) { + params['project'] = primaryObject.id; } else { delete params['project']; } // probably don't need this, but maybe something similar params["run_list"] = this.state.filters.run_list?.val; - HttpClient.get([Settings.serverUrl + '/widget/' + this.props.view.widget], params) + HttpClient.get([Settings.serverUrl, 'widget', this.props.view.widget], params) .then(response => HttpClient.handleResponse(response)) .then(data => { this.setState({ @@ -263,7 +264,7 @@ export class AccessibilityAnalysisView extends React.Component { if (node.result) { this.setState({currentTest: node.result}, () => { if (!this.state.currentTest.artifacts) { - HttpClient.get([Settings.serverUrl + '/artifact'], {resultId: this.state.currentTest.id}) + HttpClient.get([Settings.serverUrl, 'artifact'], {resultId: this.state.currentTest.id}) .then(response => HttpClient.handleResponse(response)) .then(data => { let { currentTest } = this.state; @@ -292,7 +293,7 @@ export class AccessibilityAnalysisView extends React.Component { } getRun() { - HttpClient.get([Settings.serverUrl + '/run/' + this.state.id]) + HttpClient.get([Settings.serverUrl, 'run', this.state.id]) .then(response => { response = HttpClient.handleResponse(response, 'response'); if (response.ok) { @@ -333,7 +334,7 @@ export class AccessibilityAnalysisView extends React.Component { } getResultsForPie_old() { - HttpClient.get([Settings.serverUrl + '/widget/accessibility-bar-chart'], {run_list: this.state.id}) + HttpClient.get([Settings.serverUrl, 'widget', 'accessibility-bar-chart'], {run_list: this.state.id}) .then(response => HttpClient.handleResponse(response)) .then(data => this.setState({ pieData: data, diff --git a/frontend/src/views/accessibilitydashboard.js b/frontend/src/views/accessibilitydashboard.js index 125d67e2..eb3f95f0 100644 --- a/frontend/src/views/accessibilitydashboard.js +++ b/frontend/src/views/accessibilitydashboard.js @@ -24,7 +24,6 @@ import { Settings } from '../settings'; import { buildBadge, buildParams, - getActiveProject, getFilterMode, getOperationMode, getOperationsFromField, @@ -33,6 +32,7 @@ import { } from '../utilities'; import { FilterTable, MultiValueInput, RunSummary } from '../components'; import { OPERATIONS, ACCESSIBILITY_FIELDS } from '../constants'; +import { IbutsuContext } from '../services/context'; function runToRow(run, filterFunc, analysisViewId) { let badges = []; @@ -90,6 +90,7 @@ function fieldToColumnName(fields) { } export class AccessibilityDashboardView extends React.Component { + static contextType = IbutsuContext; static propTypes = { location: PropTypes.object, navigate: PropTypes.func, @@ -303,15 +304,15 @@ export class AccessibilityDashboardView extends React.Component { let analysisViewId = ''; let params = {filter: []}; let filters = this.state.filters; - const project = getActiveProject(); - if (project) { - filters['project_id'] = {'val': project.id, 'op': 'eq'}; + const { primaryObject } = this.context; + if (primaryObject) { + filters['project_id'] = {'val': primaryObject.id, 'op': 'eq'}; } else if (Object.prototype.hasOwnProperty.call(filters, 'project_id')) { delete filters['project_id'] } // get the widget ID for the analysis view - HttpClient.get([Settings.serverUrl + '/widget-config'], {"filter": "widget=accessibility-analysis-view"}) + HttpClient.get([Settings.serverUrl, 'widget-config'], {"filter": "widget=accessibility-analysis-view"}) .then(response => HttpClient.handleResponse(response)) .then(data => { analysisViewId = data.widgets[0]?.id diff --git a/frontend/src/views/compareruns.js b/frontend/src/views/compareruns.js index a53ba215..6d5871a8 100644 --- a/frontend/src/views/compareruns.js +++ b/frontend/src/views/compareruns.js @@ -24,13 +24,14 @@ import { import { HttpClient } from '../services/http'; import { Settings } from '../settings'; import { - getActiveProject, toAPIFilter, getSpinnerRow, resultToComparisonRow } from '../utilities'; +import { IbutsuContext } from '../services/context'; export class CompareRunsView extends React.Component { + static contextType = IbutsuContext; static propTypes = { location: PropTypes.object, view: PropTypes.object @@ -132,8 +133,8 @@ export class CompareRunsView extends React.Component { if (isNew === true) { // Add project id to params - let project = getActiveProject(); - let projectId = project ? project.id : '' + const { primaryObject } = this.context; + const projectId = primaryObject ? primaryObject.id : '' filter.forEach(filter => { filter['project_id'] = {op: 'in', val: projectId}; }); @@ -270,8 +271,9 @@ export class CompareRunsView extends React.Component { ] + const { primaryObject } = this.context; // Compare runs work only when project is selected - return ( getActiveProject() && + return ( primaryObject && diff --git a/frontend/src/views/jenkinsjob.js b/frontend/src/views/jenkinsjob.js index 40f4c596..babb1ce8 100644 --- a/frontend/src/views/jenkinsjob.js +++ b/frontend/src/views/jenkinsjob.js @@ -22,7 +22,6 @@ import { HttpClient } from '../services/http'; import { Settings } from '../settings'; import { buildParams, - getActiveProject, getFilterMode, getOperationMode, getOperationsFromField, @@ -31,24 +30,26 @@ import { } from '../utilities'; import { FilterTable, MultiValueInput, RunSummary } from '../components'; import { OPERATIONS, JJV_FIELDS } from '../constants'; +import { IbutsuContext } from '../services/context'; function jobToRow(job, analysisViewId) { let start_time = new Date(job.start_time); return { cells: [ - analysisViewId ? {title: {job.job_name}} : job.job_name, + analysisViewId ? {title: {job.job_name}} : job.job_name, {title: {job.build_number}}, {title: }, job.source, job.env, start_time.toLocaleString(), - {title: See runs } + {title: See runs } ] }; } export class JenkinsJobView extends React.Component { + static contextType = IbutsuContext; static propTypes = { location: PropTypes.object, navigate: PropTypes.func, @@ -260,9 +261,8 @@ export class JenkinsJobView extends React.Component { getData() { let analysisViewId = ''; - let filters = this.state.filters; + const filters = this.state.filters; let params = this.props.view.params; - let project = getActiveProject(); // get the widget ID for the analysis view HttpClient.get([Settings.serverUrl, 'widget-config'], {"filter": "widget=jenkins-analysis-view"}) @@ -277,8 +277,10 @@ export class JenkinsJobView extends React.Component { if (!this.props.view) { return; } - if (project) { - params['project'] = project.id; + + const { primaryObject } = this.context; + if (primaryObject) { + params['project'] = primaryObject.id; } else { delete params['project']; diff --git a/frontend/src/views/jenkinsjobanalysis.js b/frontend/src/views/jenkinsjobanalysis.js index 7323c84c..65596b55 100644 --- a/frontend/src/views/jenkinsjobanalysis.js +++ b/frontend/src/views/jenkinsjobanalysis.js @@ -9,15 +9,16 @@ import { import { HttpClient } from '../services/http'; import { Settings } from '../settings'; import { - getActiveProject, parseFilter, } from '../utilities'; import { FilterHeatmapWidget, GenericAreaWidget, GenericBarWidget } from '../widgets'; import { ParamDropdown } from '../components'; import { HEATMAP_MAX_BUILDS } from '../constants' +import { IbutsuContext } from '../services/context'; export class JenkinsJobAnalysisView extends React.Component { + static contextType = IbutsuContext; static propTypes = { location: PropTypes.object, navigate: PropTypes.func, @@ -65,9 +66,9 @@ export class JenkinsJobAnalysisView extends React.Component { return; } let params = this.props.view.params; - let project = getActiveProject(); - if (project) { - params['project'] = project.id; + const { primaryObject } = this.context; + if (primaryObject) { + params['project'] = primaryObject.id; } else { delete params['project']; diff --git a/scripts/ibutsu-pod.sh b/scripts/ibutsu-pod.sh index 1584b4eb..8c33057e 100755 --- a/scripts/ibutsu-pod.sh +++ b/scripts/ibutsu-pod.sh @@ -190,7 +190,7 @@ podman run -d \ -w /mnt \ -v./frontend:/mnt/:Z \ node:18 \ - /bin/bash -c "npm install --no-save --no-package-lock yarn && + /bin/bash -c "node --dns-result-order=ipv4first /usr/bin/npm install --no-save --no-package-lock yarn && yarn install && CI=1 yarn devserver" echo "done."