From 11a436260a62119104ce75ab94f8659c42241af4 Mon Sep 17 00:00:00 2001 From: Mike Shriver Date: Tue, 24 Sep 2024 16:52:33 -0400 Subject: [PATCH] React Context and Project Nested Routing (#508) * autoupdate pre-commit * Frontend refactor for context and routing Frontend updates for admin portal-edit and portal-list are working React context used to track active dashboard and project, instead of browser local storage. Dashboard, IbutsuHeader, IbutsuPage rewritten for using the context. Static routes set for project dashboard/runs/results/reportbuilder using UUID. Page component state needs to be updated in some areas when navigation orginates from the URL with params set and not component selection. Project selection and dashboard selection are hooked up to the routing with ugly event handler prop passing. Moving these to functional components will allow using useEffect with dependencies instead to control interaction between these components sidebar moved into new functional component, project views need to be accounted for in component state or in the context itself. Now when a project is not selected, there is no page rendered (no sidebar). This will need an empty state page to look good, something easy just saying to select a project or portal. Class component callbacks on setState don't pick up modified context, so I'm having to hack a function parameter into the callback functions Split Admin and Profile pages into separate component for sane routing Both now use Page instead of IbutsuPage, keeping IbutsuHeader Use an outlet in the admin and profile pages for easy routing run and result updates for routing change path relative links for react-router so that runs and results are nested under `/project/:id` Page refreshes now set both the project and dashboard selection from URL params! * remove utility functions, update Links use path relative links from components where the route relative paths don't compose correctly. Update widget-config-controller to allow view type widgets to not have any project set needs testing Updating FileUpload to remove params Updating unit test to provide the necessary context for the FileUpload component * Updates for theme handling and Upload tooltip use the HTML class to control theme instead of applying to every element Update upload button to use ariaDisabled, add tooltip * Remove FileUpload onClick prop It's not necessary for the usecase and the cypress test was failing to detect the mocked method being called. * Add empty state for no project selection --- .pre-commit-config.yaml | 4 +- .../controllers/widget_config_controller.py | 3 + frontend/eslint.config.js | 8 + frontend/package.json | 5 + frontend/src/admin.js | 52 ++-- frontend/src/app.js | 136 +++++------ frontend/src/base.js | 46 ++-- frontend/src/components/FileUpload.spec.cy.js | 20 +- frontend/src/components/admin-page.js | 64 +++++ frontend/src/components/elementWrapper.js | 2 +- frontend/src/components/fileupload.js | 32 +-- frontend/src/components/filtertable.js | 12 +- frontend/src/components/ibutsu-header.js | 195 +++++++++++---- frontend/src/components/ibutsu-page.js | 90 +++---- frontend/src/components/profile-page.js | 59 +++++ frontend/src/components/result.js | 6 +- frontend/src/components/sidebar.js | 91 +++++++ frontend/src/components/test-history.js | 2 +- frontend/src/components/user-dropdown.js | 12 +- frontend/src/components/view.js | 5 +- frontend/src/constants.js | 2 + frontend/src/dashboard.js | 230 +++++++++++------- frontend/src/index.js | 4 + frontend/src/login.js | 24 +- frontend/src/pages/admin/home.js | 30 +-- frontend/src/pages/profile/user.js | 2 + frontend/src/profile.js | 58 ++--- frontend/src/report-builder.js | 15 +- frontend/src/result-list.js | 12 +- frontend/src/result.js | 2 +- frontend/src/run-list.js | 29 ++- frontend/src/run.js | 8 +- frontend/src/services/context.js | 32 +++ frontend/src/utilities.js | 60 +++-- frontend/src/views/accessibilityanalysis.js | 17 +- frontend/src/views/accessibilitydashboard.js | 11 +- frontend/src/views/compareruns.js | 10 +- frontend/src/views/jenkinsjob.js | 16 +- frontend/src/views/jenkinsjobanalysis.js | 9 +- scripts/ibutsu-pod.sh | 2 +- 40 files changed, 909 insertions(+), 508 deletions(-) create mode 100644 frontend/eslint.config.js create mode 100644 frontend/src/components/admin-page.js create mode 100644 frontend/src/components/profile-page.js create mode 100644 frontend/src/components/sidebar.js create mode 100644 frontend/src/services/context.js 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."