From ac47811e1ce9e8801972369727e7642f4865253c Mon Sep 17 00:00:00 2001 From: mshriver Date: Mon, 29 Jul 2024 11:02:38 -0400 Subject: [PATCH 1/4] autoupdate pre-commit --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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: From f082196f307ff31bc6c45eb3509aafe124c8c121 Mon Sep 17 00:00:00 2001 From: mshriver Date: Tue, 2 Jul 2024 07:17:51 -0400 Subject: [PATCH 2/4] DRAFT: 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 In progress: 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! DRAFT 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 --- .../controllers/admin/project_controller.py | 8 +- .../controllers/widget_config_controller.py | 3 + frontend/eslint.config.js | 8 + frontend/package.json | 5 + frontend/src/admin.js | 56 +- frontend/src/app.js | 149 ++-- frontend/src/base.js | 47 +- frontend/src/components/admin-page.js | 62 ++ frontend/src/components/elementWrapper.js | 2 +- frontend/src/components/filtertable.js | 13 +- frontend/src/components/ibutsu-header.js | 169 +++- frontend/src/components/ibutsu-page.js | 59 +- frontend/src/components/portals-page.js | 1 + frontend/src/components/profile-page.js | 55 ++ 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 | 4 +- frontend/src/dashboard.js | 199 +++-- frontend/src/pages/admin/home.js | 30 +- frontend/src/pages/admin/portal-edit.js | 483 ++++++++++++ frontend/src/pages/admin/portal-list.js | 258 +++++++ frontend/src/portal.js | 1 + 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 | 22 +- 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 +- frontend/yarn.lock | 731 +++++++++--------- scripts/ibutsu-pod.sh | 2 +- 38 files changed, 1929 insertions(+), 756 deletions(-) create mode 100644 frontend/eslint.config.js create mode 100644 frontend/src/components/admin-page.js create mode 100644 frontend/src/components/portals-page.js create mode 100644 frontend/src/components/profile-page.js create mode 100644 frontend/src/components/sidebar.js create mode 100644 frontend/src/pages/admin/portal-edit.js create mode 100644 frontend/src/pages/admin/portal-list.js create mode 100644 frontend/src/portal.js create mode 100644 frontend/src/services/context.js diff --git a/backend/ibutsu_server/controllers/admin/project_controller.py b/backend/ibutsu_server/controllers/admin/project_controller.py index dad5d6a2..eb75eb55 100644 --- a/backend/ibutsu_server/controllers/admin/project_controller.py +++ b/backend/ibutsu_server/controllers/admin/project_controller.py @@ -163,8 +163,12 @@ def admin_delete_project(id_, token_info=None, user=None): check_user_is_admin(user) if not is_uuid(id_): return f"Project ID {id_} is not in UUID format", HTTPStatus.BAD_REQUEST - project = Project.query.get(id_) - if not project: + + if project := Project.query.get(id_): + session.delete(project) + session.commit() + return HTTPStatus.OK.phrase, HTTPStatus.OK + else: abort(HTTPStatus.NOT_FOUND) session.delete(project) session.commit() 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..291ea299 100644 --- a/frontend/src/admin.js +++ b/frontend/src/admin.js @@ -1,22 +1,20 @@ 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 { PortalList } from './pages/admin/portal-list'; +import { PortalEdit } from './pages/admin/portal-edit'; + import './app.css'; +import AdminPage from './components/admin-page'; export class Admin extends React.Component { @@ -34,34 +32,22 @@ export class Admin extends React.Component { } render() { - const navigation = ( - - ); - return ( - - - - }/> - } /> - } /> - } /> - } /> - - - + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + }/> + ); } } diff --git a/frontend/src/app.js b/frontend/src/app.js index 13bf29ef..8ebfb995 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,14 @@ 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 { 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 +29,86 @@ 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 */} + + } + /> + } + /> + + + + } + /> + } + /> + + } + /> + } + /> + + } + /> + + } + /> + + {/* } /> */} + + + {/* Nested Portal routes */} + {/* } + /> + } + /> + } + /> */} + + + ); } } diff --git a/frontend/src/base.js b/frontend/src/base.js index 0c27cd18..c66a7971 100644 --- a/frontend/src/base.js +++ b/frontend/src/base.js @@ -3,35 +3,40 @@ 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/admin-page.js b/frontend/src/components/admin-page.js new file mode 100644 index 00000000..81ca2812 --- /dev/null +++ b/frontend/src/components/admin-page.js @@ -0,0 +1,62 @@ +import React from 'react'; + +import { + Nav, + NavList, + Page +} 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 + PropTypes + const navigation = ( + // TODO what is onNavSelect doing here ... + + ); + + 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/filtertable.js b/frontend/src/components/filtertable.js index a411a5b4..fad16a4d 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,9 @@ export class MetaFilter extends React.Component { let api_filter = toAPIFilter(customFilters).join(); console.debug('APIFILTER: ' + customFilters); - let project = getActiveProject(); - let projectId = project ? project.id : '' + // TODO handle portal + const { primaryObject } = this.context; + let projectId = primaryObject ? primaryObject.id : '' // make runId optional let params = {} @@ -290,8 +293,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 28aa6e80..56a164d4 100644 --- a/frontend/src/components/ibutsu-header.js +++ b/frontend/src/components/ibutsu-header.js @@ -34,40 +34,94 @@ 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 { + 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: [], + // portal state + isPortalSelectorOpen: false, + selectedPortal: '', + portalInputValue: '', + portalFilterValue: '', + portals: [], + filteredPortals: [], + // misc + isAboutOpen: false, isDarkTheme: getTheme() === 'dark', - version: props.version }; } + sync_context = () => { + // TODO handle portal_id + // 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,11 +129,37 @@ export class IbutsuHeader extends React.Component { this.eventEmitter.emit('showNotification', type, title, message, action, timeout, key); } - emitProjectChange() { + 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'); + } + }); + } + + emitProjectChange(value = null) { if (!this.eventEmitter) { return; } - this.eventEmitter.emit('projectChange'); + this.eventEmitter.emit('projectChange', value); + } + + emitPortalChange() { + // the portal selector doesnt even exist yet + if (!this.eventEmitter) { + return; + } + this.eventEmitter.emit('portalChange'); } emitThemeChange() { @@ -89,14 +169,24 @@ export class IbutsuHeader extends React.Component { this.eventEmitter.emit('themeChange'); } - getProjects() { - const params = {pageSize: 10}; + 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], + }) + } + ); } onBeforeUpload = (files) => { @@ -144,11 +234,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,39 +246,49 @@ 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'); @@ -199,10 +299,16 @@ export class IbutsuHeader extends React.Component { 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 +344,7 @@ export class IbutsuHeader extends React.Component { { this.showNotification(type, title, message, action, timeout, key); }); this.props.eventEmitter.on('themeChange', this.setTheme); + this.props.eventEmitter.on('projectChange', () => { + }); + // TODO: empty state props.children override + } showNotification(type, title, message, action, timeout, key) { @@ -81,32 +88,13 @@ export class IbutsuPage extends React.Component { } } - 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'); - } - }); - } - - componentWillUnmount() { - if (this.versionCheckId) { - clearInterval(this.versionCheckId); - } - } - componentDidMount() { this.setTheme(); - this.checkVersion(); - this.versionCheckId = setInterval(() => this.checkVersion(), VERSION_CHECK_TIMEOUT); } render() { document.title = this.props.title || 'Ibutsu'; + // TODO: render project or portal depending on menutoggle + select event return ( @@ -117,17 +105,12 @@ export class IbutsuPage extends React.Component { ))} } - sidebar={ - - - {this.props.navigation} - - } + header={} + sidebar={} isManagedSidebar={true} style={{position: "relative"}} > - {this.props.children} + ); diff --git a/frontend/src/components/portals-page.js b/frontend/src/components/portals-page.js new file mode 100644 index 00000000..85f7872a --- /dev/null +++ b/frontend/src/components/portals-page.js @@ -0,0 +1 @@ +{/* TODO: portals-page, will mirror the projects::dashboard view on ibutsu-page currently */} diff --git a/frontend/src/components/profile-page.js b/frontend/src/components/profile-page.js new file mode 100644 index 00000000..56e7d1fa --- /dev/null +++ b/frontend/src/components/profile-page.js @@ -0,0 +1,55 @@ +import React from 'react'; + +import { + Nav, + NavList, + Page +} from '@patternfly/react-core'; + +import { NavLink, Outlet} from 'react-router-dom'; + + +import ElementWrapper from './elementWrapper'; +import { IbutsuHeader } from './ibutsu-header'; +import PropTypes from 'prop-types'; + + + +const ProfilePage = (props) => { + // TODO useEffect + + const navigation = ( + // TODO what is onNavSelect doing here ... + + ); + + document.title = "Profile | Ibutsu"; + + return ( + + } + sidebar={navigation} + isManagedSidebar={true} + style={{position: "relative"}} + > + + + + ); +} + +ProfilePage.propTypes = { + eventEmitter: PropTypes.object, +}; + +export default ProfilePage diff --git a/frontend/src/components/result.js b/frontend/src/components/result.js index 7261297e..fb8ead95 100644 --- a/frontend/src/components/result.js +++ b/frontend/src/components/result.js @@ -239,7 +239,7 @@ export class ResultView extends React.Component { resultIcon = getIconForResult(testResult.result); startTime = new Date(testResult.start_time); parameters = Object.keys(testResult.params).map((key) =>
{key} = {testResult.params[key]}
); - runLink = {testResult.run_id}; + runLink = {testResult.run_id}; } const jsonViewTheme = { scheme: 'monokai', @@ -296,7 +296,7 @@ export class ResultView extends React.Component { Component:, - {testResult.component} + {testResult.component} ]} /> @@ -499,7 +499,7 @@ export class ResultView extends React.Component { Source:, - {testResult.source} + {testResult.source} ]} /> diff --git a/frontend/src/components/sidebar.js b/frontend/src/components/sidebar.js new file mode 100644 index 00000000..31937b89 --- /dev/null +++ b/frontend/src/components/sidebar.js @@ -0,0 +1,91 @@ + +import React, { useContext, useState } from "react"; +import PropTypes from 'prop-types'; + +import { Link } from 'react-router-dom'; +import { IbutsuContext } from "../services/context"; +import { PageSidebar, + PageSidebarBody, + Nav, + NavList, + } from '@patternfly/react-core'; +import { HttpClient } from "../services/http"; +import { Settings } from "../settings"; + + +const IbutsuSidebar = (props) => { + const context = useContext(IbutsuContext); + const [views, setViews] = useState(); + props.eventEmitter.on('projectChange', (project) => { + setProjectViews(project); // TODO somehow this is getting triggered multiple times on project select + }) + // const params = useParams(); + + + function setProjectViews(project) { + const { primaryObject } = context; + const targetProject = project ?? primaryObject; + + let params = {'filter': ['type=view', 'navigable=true']}; + + // read selected project from location + params['filter'].push('project_id=' + targetProject.id); + + HttpClient.get([Settings.serverUrl, 'widget-config'], params) + .then(response => HttpClient.handleResponse(response)) + .then(data => { + //debugger; //eslint-disable-line no-debugger + + data.widgets.forEach(widget => { + if (targetProject) { + widget.params['project'] = targetProject.id; + } + else { + delete widget.params['project']; + } + }); + // TODO: views just part of the context instead of state of this component? + console.log('setting views: '); + console.log(data.widgets); + setViews(data.widgets)}); + } + + const { primaryType, primaryObject } = context; + if ( primaryType == 'project' && primaryObject ) { + return ( + + + + + + ); + } +}; + +IbutsuSidebar.propTypes = { + eventEmitter: PropTypes.object, +}; + +export default IbutsuSidebar; diff --git a/frontend/src/components/test-history.js b/frontend/src/components/test-history.js index 6379a16b..f4419fc2 100644 --- a/frontend/src/components/test-history.js +++ b/frontend/src/components/test-history.js @@ -214,7 +214,7 @@ export class TestHistoryTable extends React.Component { .then(data => this.setState({ lastPassedDate: - + {new Date(data.results[0].start_time).toLocaleString()} diff --git a/frontend/src/components/user-dropdown.js b/frontend/src/components/user-dropdown.js index e2fe3b29..b405018b 100644 --- a/frontend/src/components/user-dropdown.js +++ b/frontend/src/components/user-dropdown.js @@ -80,11 +80,11 @@ export class UserDropdown extends React.Component { > - Profile + Profile {!!this.state.isSuperAdmin && - Administration + Administration } diff --git a/frontend/src/dashboard.js b/frontend/src/dashboard.js index 486eb49e..28581476 100644 --- a/frontend/src/dashboard.js +++ b/frontend/src/dashboard.js @@ -46,67 +46,85 @@ import { ResultAggregatorWidget, ResultSummaryWidget } from './widgets'; -import { getActiveProject, getActiveDashboard } from './utilities.js'; +import { IbutsuContext } from './services/context.js'; export class Dashboard extends React.Component { + static contextType = IbutsuContext; static propTypes = { - eventEmitter: PropTypes.object + eventEmitter: PropTypes.object, + navigate: PropTypes.func, + params: PropTypes.object, } constructor(props) { super(props); - let dashboard = getActiveDashboard() || this.getDefaultDashboard(); this.state = { widgets: [], filteredDashboards: [], dashboards: [], - selectedDashboard: dashboard, + selectedDashboard: null, isDashboardSelectorOpen: false, isNewDashboardOpen: false, isWidgetWizardOpen: false, isEditModalOpen: false, editWidgetData: {}, - dashboardInputValue: dashboard?.title || '', + dashboardInputValue: '', filterValueDashboard: '' }; - props.eventEmitter.on('projectChange', () => { - this.clearDashboards(); - this.getDashboards(); + props.eventEmitter.on('projectChange', (value) => { + this.getDashboards(value); + this.getDefaultDashboard(value); }); } - getDefaultDashboard() { - let project = getActiveProject(); - if (project && project.defaultDashboard) { - console - const dashboard = JSON.stringify(project.defaultDashboard) - localStorage.setItem('dashboard', dashboard); - return project.defaultDashboard; + sync_context = () => { + // Active dashboard + const { activeDashboard } = this.context; + const { selectedDashboard } = this.state; + const paramDash = this.props.params?.dashboard_id; + let updatedDash = undefined; + // API call to update context + if ( paramDash && activeDashboard?.id !== paramDash) { + HttpClient.get([Settings.serverUrl, 'dashboard', paramDash]) + .then(response => HttpClient.handleResponse(response)) + .then(data => { + const { setActiveDashboard } = this.context; + setActiveDashboard(data); + updatedDash = data; + this.setState({ + selectedDashboard: data, + isDashboardSelectorOpen: false, + filterValueDashboard: '', + dashboardInputValue: data.title, + }); // callback within class component won't have updated context + // TODO don't pass value when converting to functional component + this.getWidgets(data); + }); } - else { - return null; + + if (updatedDash && !selectedDashboard ) { + this.setState({ + selectedDashboard: updatedDash, + dashboardInputValue: updatedDash.title + }) } } - clearDashboards() { - localStorage.removeItem('dashboard'); - this.setState({ - selectedDashboard: null, - filteredDashboards: [], - dashboardInputValue: '', - filterValueDashboard: '' - }); - } + getDashboards = (handledOject = null) => { + // value is checked because of handler scope not seeing context state updates + // TODO: react-router loaders would be way better + const { primaryObject } = this.context; + const paramProject = this.props.params?.project_id; + const primaryObjectId = handledOject?.id ?? primaryObject?.id ?? paramProject; - getDashboards() { - let project = getActiveProject(); - if (!project) { + if (!primaryObjectId) { this.setState({dashboardInputValue: ''}) return; } + // TODO: set based on primaryType let params = { - 'project_id': project.id, + 'project_id': primaryObjectId, 'pageSize': 10 }; @@ -116,23 +134,60 @@ export class Dashboard extends React.Component { HttpClient.get([Settings.serverUrl, 'dashboard'], params) .then(response => HttpClient.handleResponse(response)) .then(data => { - this.setState({dashboards: data['dashboards'], filteredDashboards: data['dashboards']}, this.getWidgets); + this.setState({dashboards: data['dashboards'], filteredDashboards: data['dashboards']}); }); } - getWidgets() { + getDefaultDashboard = (handledObject = null) => { + const { primaryObject, activeDashboard, setActiveDashboard } = this.context; + const paramProject = this.props.params?.project_id; + + let targetObject = handledObject ?? primaryObject ?? paramProject; + + if (typeof(targetObject) === 'string') { + HttpClient.get([Settings.serverUrl, 'project', paramProject]) + .then(response => HttpClient.handleResponse(response)) + .then(data => { + targetObject = data; + }); + + } + + if ( !activeDashboard && targetObject?.defaultDashboard ){ + setActiveDashboard(targetObject.defaultDashboard); + this.setState({ + 'selectedDashboard': targetObject.defaultDashboard, + 'dashboardInputValue': targetObject.defaultDashboard?.title + }) + } else { + this.setState({ + 'selectedDashboard': 'Select a dashboard', + 'dashboardInputValue': 'Select a dashboard' + }) + } + } + + getWidgets = (dashboard) => { let params = {'type': 'widget'}; - let dashboard = getActiveDashboard() || this.getDefaultDashboard(); - if (!dashboard) { + const { activeDashboard } = this.context; + // TODO don't pass value when converting to functional component + let target_dash = null; + if (dashboard === undefined) { + target_dash = activeDashboard; + } else { + target_dash = dashboard; + } + if (!target_dash) { return; } - params['filter'] = 'dashboard_id=' + dashboard.id; + params['filter'] = 'dashboard_id=' + target_dash.id; HttpClient.get([Settings.serverUrl, 'widget-config'], params) .then(response => HttpClient.handleResponse(response)) .then(data => { // set the widget project param + // TODO: set based on primaryType data.widgets.forEach(widget => { - widget.params['project'] = dashboard.project_id; + widget.params['project'] = target_dash.project_id; }); this.setState({widgets: data.widgets}); }); @@ -143,23 +198,34 @@ export class Dashboard extends React.Component { }; onDashboardSelect = (_event, value) => { - const dashboard = JSON.stringify(value); - localStorage.setItem('dashboard', dashboard); + const { setActiveDashboard } = this.context; + setActiveDashboard(value); this.setState({ selectedDashboard: value, isDashboardSelectorOpen: false, filterValueDashboard: '', dashboardInputValue: value.title, - }, this.getWidgets); + }); // callback within class component won't have updated context + // TODO don't pass value when converting to functional component + this.getWidgets(value); + + // does it really matter whether I read from params or the context here? + // they should be the same, reading from params 'feels' better + this.props.navigate('/project/' + this.props.params?.project_id + '/dashboard/' + value?.id) }; onDashboardClear = () => { - localStorage.removeItem('dashboard'); + const { setActiveDashboard } = this.context; + setActiveDashboard(); this.setState({ - selectedDashboard: null, - dashboardInputValue: '', + selectedDashboard: 'Select a dashboard', + dashboardInputValue: 'Select a dashboard', filterValueDashboard: '' - }, this.getDashboards, this.getWidgets); + }); + // TODO convert to functional component and rely on context updating within callbacks + this.getWidgets(null); + + this.props.navigate('/project/' + this.props.params?.project_id + '/dashboard/') } onTextInputChange = (_event, value) => { @@ -200,12 +266,12 @@ export class Dashboard extends React.Component { } onDeleteDashboard = () => { - const dashboard = getActiveDashboard(); + const { activeDashboard, setActiveDashboard } = this.context; - HttpClient.delete([Settings.serverUrl, 'dashboard', dashboard.id]) + HttpClient.delete([Settings.serverUrl, 'dashboard', activeDashboard.id]) .then(response => HttpClient.handleResponse(response)) .then(() => { - localStorage.removeItem('dashboard'); + setActiveDashboard(); this.getDashboards(); this.setState({ isDeleteDashboardOpen: false, @@ -224,9 +290,10 @@ export class Dashboard extends React.Component { } onEditWidgetSave = (editWidget) => { - const project = getActiveProject(); - if (!editWidget.project_id && project) { - editWidget.project_id = project.id; + const { primaryObject } = this.context; + // TODO: handle based on primaryType + if (!editWidget.project_id && primaryObject) { + editWidget.project_id = primaryObject.id; } this.setState({isEditModalOpen: false}); editWidget.id = this.state.currentWidgetId @@ -271,16 +338,19 @@ export class Dashboard extends React.Component { } onNewWidgetSave = (newWidget) => { - const project = getActiveProject(); - if (!newWidget.project_id && project) { - newWidget.project_id = project.id; + const { primaryObject } = this.context; + // TODO: handle based on primaryType + if (!newWidget.project_id && primaryObject) { + newWidget.project_id = primaryObject.id; } HttpClient.post([Settings.serverUrl, 'widget-config'], newWidget).then(() => { this.getWidgets() }); this.setState({isWidgetWizardOpen: false}); } componentDidMount() { + this.sync_context(); this.getDashboards(); + this.getDefaultDashboard(); this.getWidgets(); } @@ -307,9 +377,8 @@ export class Dashboard extends React.Component { render() { document.title = 'Dashboard | Ibutsu'; - const { widgets } = this.state || this.getWidgets(); - const project = getActiveProject(); - const dashboard = getActiveDashboard() || this.getDefaultDashboard(); + const { widgets } = this.state; + const { primaryObject, activeDashboard } = this.context; const toggle = toggleRef => ( @@ -411,7 +480,7 @@ export class Dashboard extends React.Component { aria-label="Delete dashboard" variant="plain" title="Delete dashboard" - isDisabled={!dashboard} + isDisabled={!activeDashboard} onClick={this.onDeleteDashboardClick} > @@ -434,7 +503,7 @@ export class Dashboard extends React.Component { - {!!project && !!dashboard && !!widgets && + {!!primaryObject && !!activeDashboard && !!widgets && {widgets.map(widget => { if (KNOWN_WIDGETS.includes(widget.widget)) { @@ -529,7 +598,7 @@ export class Dashboard extends React.Component { })} } - {!project && + {!primaryObject && } headingLevel="h4" /> @@ -538,7 +607,7 @@ export class Dashboard extends React.Component { } - {!!project && !dashboard && + {!!primaryObject && !activeDashboard && } headingLevel="h4" /> @@ -550,7 +619,7 @@ export class Dashboard extends React.Component { } - {(!!project && !!dashboard && widgets.length === 0) && + {(!!primaryObject && !!activeDashboard && widgets.length === 0) && } headingLevel="h4" /> @@ -564,13 +633,13 @@ export class Dashboard extends React.Component { } - - - Administration - - - - - ); - } -} +const AdminHome = () => { + return ( + + + + Administration + + + + + ); +}; + +AdminHome.propTypes = {}; + +export default AdminHome; diff --git a/frontend/src/pages/admin/portal-edit.js b/frontend/src/pages/admin/portal-edit.js new file mode 100644 index 00000000..211b8e39 --- /dev/null +++ b/frontend/src/pages/admin/portal-edit.js @@ -0,0 +1,483 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { + ActionGroup, + Alert, + Button, + Card, + CardBody, + Form, + FormGroup, + FormHelperText, + HelperText, + HelperTextItem, + MenuToggle, + PageSection, + PageSectionVariants, + Select, + SelectList, + SelectOption, + TextInput, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + Title +} from '@patternfly/react-core'; +import { Link } from 'react-router-dom'; + +import { TimesIcon } from '@patternfly/react-icons'; + +import { HttpClient } from '../../services/http'; +import { Settings } from '../../settings'; +import { dashboardToOption } from '../../utilities.js'; + + +function userToOption(user) { + if (!user) { + return ''; + } + return { + user: user, + toString: function () { return this.user.name; }, + compareTo: function (value) { + if (value.user) { + return this.user.id === value.user.id; + } + return this.user.name.toLowerCase().includes(value.toLowerCase()) || + this.user.email.includes(value.toLowerCase()); + } + }; +} + +export class PortalEdit extends React.Component { + static propTypes = { + params: PropTypes.object, + location: PropTypes.object, + navigate: PropTypes.func, + }; + + constructor(props) { + super(props); + this.state = { + id: props.params.id, + portal: {}, + isOwnerOpen: false, + selectedOwner: {}, + filterValueOwner: '', + filteredUsers: [], + inputValueOwner: '', + filteredDashboards: [], + dashboards: [], + isDashboardOpen: false, + selectedDashboard: null, + filterValueDashboard: '', + inputValueDashboard: '', + }; + } + + onPortalNameChanged = (value) => { + const { portal } = this.state; + portal.name = value; + this.setState({portal}); + } + + onPortalTitleChanged = (value) => { + const { portal } = this.state; + portal.title = value; + this.setState({portal}); + } + + onSubmitClick = () => { + const { portal, selectedOwner, selectedDashboard } = this.state; + portal.owner_id = selectedOwner ? selectedOwner.id : null; + portal.default_dashboard_id = selectedDashboard ? selectedDashboard.id : null; + delete portal.owner; + // delete portal.defaultDashboard; + this.savePortal(portal.id || null, portal) + .then(() => this.props.navigate(-1)) + .catch((error) => console.error(error)); + }; + + onOwnerToggle = () => { + this.setState({isOwnerOpen: !this.state.isOwnerOpen}); + }; + + onDashboardToggle = () => { + this.setState({isDashboardOpen: !this.state.isDashboardOpen}); + }; + + onOwnerInputChange = (_event, value) => { + this.setState({inputValueOwner: value}); + this.setState({filterValueOwner: value}); + }; + + onOwnerSelect = (event, value) => { + this.setState({ + selectedOwner: value.user, + isOwnerOpen: false, + filterValueOwner: '', + inputValueOwner: value.user.name + }); + }; + + onOwnerClear = () => { + this.setState({ + selectedOwner: null, + inputValueOwner: '', + filterValueOwner: '' + }); + } + + getPortal(portalId) { + HttpClient.get([Settings.serverUrl, 'admin', 'portal', portalId]) + .then(response => { + response = HttpClient.handleResponse(response, 'response'); + return response.json(); + }) + .then(portal => { + this.setState({portal: portal, selectedOwner: portal.owner, + inputValueOwner: portal.owner?.name, + selectedDashboard: portal.defaultDashboard, + inputValueDashboard: portal.defaultDashboard?.title}); + }) + .catch(error => console.error(error)); + } + + onDashboardToggle = () => { + this.setState({isDashboardOpen: !this.state.isDashboardOpen}); + }; + + onDashboardSelect = (event, value) => { + this.setState({ + selectedDashboard: value.dashboard, + isDashboardOpen: false, + filterValueDashboard: '', + inputValueDashboard: value.dashboard.title + }); + }; + + onDashboardClear = () => { + this.setState({ + selectedDashboard: null, + inputValueDashboard: '', + filterValueDashboard: '' + }); + } + + onDashboardInputChange = (_event, value) => { + this.setState({inputValueDashboard: value}); + this.setState({filterValueDashboard: value}); + }; + + getDashboards() { + if (!this.state.id || this.state.id == "new" || !this.state.portal) { + return; + } + let params = { + 'portal_id': this.state.id, + 'pageSize': 10 + }; + HttpClient.get([Settings.serverUrl, 'dashboard'], params) + .then(response => HttpClient.handleResponse(response)) + .then(data => this.setState({dashboards: data['dashboards'], filteredDashboards: data['dashboards']})); + } + + getUsers() { + HttpClient.get([Settings.serverUrl, 'admin', 'user']) + .then(response => { + response = HttpClient.handleResponse(response, 'response'); + return response.json(); + }) + .then(data => this.setState({users: data.users, filteredUsers: data.users})) + .catch(error => console.error(error)); + } + + savePortal(portalId, portal) { + let request = null; + if (!portalId) { + request = HttpClient.post([Settings.serverUrl, 'admin', 'portal'], portal); + } + else { + request = HttpClient.put([Settings.serverUrl, 'admin', 'portal', portalId], {}, portal); + } + return request.then(response => HttpClient.handleResponse(response, 'response')) + .then(response => response.json()); + } + + componentDidMount() { + if (this.state.id === 'new') { + this.setState({portal: {title: 'New portal', name: 'new-portal'}}); + } + else { + this.getPortal(this.state.id); + this.getDashboards(); + } + this.getUsers(); + } + + componentDidUpdate(prevProps, prevState) { + if ( + prevState.filterValueDashboard !== this.state.filterValueDashboard + ) { + let newSelectOptionsDashboard = this.state.dashboards; + if (this.state.inputValueDashboard) { + newSelectOptionsDashboard = this.state.dashboards.filter(menuItem => + String(menuItem.title).toLowerCase().includes(this.state.filterValueDashboard.toLowerCase()) + ); + if (newSelectOptionsDashboard.length === 0) { + newSelectOptionsDashboard = [{ + isDisabled: true, + value: {}, + title: `No results found for "${this.state.filterValueDashboard}"`, + }]; + } + + if (!this.state.isDashboardOpen) { + this.setState({ isDashboardOpen: true }); + } + } + + this.setState({ + filteredDashboards: newSelectOptionsDashboard, + }); + } + + if ( + prevState.filterValueOwner !== this.state.filterValueOwner + ) { + let newSelectOptionsUser = this.state.users; + if (this.state.inputValueOwner) { + newSelectOptionsUser = this.state.users.filter(menuItem => + String(menuItem.name).toLowerCase().includes(this.state.filterValueOwner.toLowerCase()) + ); + if (newSelectOptionsUser.length === 0) { + newSelectOptionsUser = [{ + isDisabled: true, + value: {}, + name: `No results found for "${this.state.filterValueOwner}"`, + }]; + } + + if (!this.state.isOwnerOpen) { + this.setState({ isOwnerOpen: true }); + } + } + + this.setState({ + filteredUsers: newSelectOptionsUser, + }); + } + } + + + render() { + const { portal, filteredUsers, selectedOwner, filteredDashboards, selectedDashboard, inputValueDashboard, inputValueOwner } = this.state; + + const toggleOwner = toggleRef => ( + + + + + {(!!inputValueOwner) && ( + + )} + + + + ) + + const toggleDashboard = toggleRef => ( + + + + + {(!!inputValueDashboard) && ( + + )} + + + + ) + + return ( + + + + Portals / {portal && portal.title} + + + + {!portal && } + {portal && + + +
+ + this.onPortalTitleChanged(value)} + /> + + + The portal‘s friendly name + + + + + this.onPortalNameChanged(value)} + /> + + + The portal‘s machine name + + + + + + + + The user who owns the Portal + + + + + + + + The default dashboard for the Portal + + + + + + + +
+
+
+ } +
+
+ ); + } +} diff --git a/frontend/src/pages/admin/portal-list.js b/frontend/src/pages/admin/portal-list.js new file mode 100644 index 00000000..07cde110 --- /dev/null +++ b/frontend/src/pages/admin/portal-list.js @@ -0,0 +1,258 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { + Button, + Card, + CardBody, + Flex, + FlexItem, + Modal, + PageSection, + PageSectionVariants, + Text, + TextContent, + TextInput +} from '@patternfly/react-core'; +import { PencilAltIcon, PlusCircleIcon, TrashIcon } from '@patternfly/react-icons'; +import { Link } from 'react-router-dom'; + +import { HttpClient } from '../../services/http'; +import { Settings } from '../../settings'; +import { debounce, getSpinnerRow } from '../../utilities'; +import { FilterTable } from '../../components'; + +function portalToRow(portal, onDeleteClick) { + return { + cells: [ + {title: portal.title}, + {title: portal.name}, + {title: portal.owner && portal.owner.name}, + { + title: ( +
+ +   + +
+ ) + } + ] + } +} + +export class PortalList extends React.Component { + static propTypes = { + location: PropTypes.object, + navigate: PropTypes.func, + } + + constructor(props) { + super(props); + const params = new URLSearchParams(props.location.search); + let page = 1, pageSize = 20; + if (params.toString() !== '') { + for(let pair of params) { + if (pair[0] === 'page') { + page = parseInt(pair[1]); + } + else if (pair[0] === 'pageSize') { + pageSize = parseInt(pair[1]); + } + } + } + this.state = { + columns: ['Title', 'Name', 'Owner', ''], + rows: [getSpinnerRow(4)], + portals: [], + page: page, + pageSize: pageSize, + totalItems: 0, + totalPages: 0, + isError: false, + isEmpty: false, + selectedPortal: null, + isDeleting: false, + isDeleteModalOpen: false, + textFilter: '' + }; + } + + updateUrl() { + let params = []; + params.push('page=' + this.state.page); + params.push('pageSize=' + this.state.pageSize); + this.props.navigate('/admin/portals?' + params.join('&')); + } + + setPage = (_event, pageNumber) => { + this.setState({page: pageNumber}, () => { + this.updateUrl(); + }); + } + + setPageSize = (_event, perPage) => { + this.setState({pageSize: perPage}, () => { + this.updateUrl(); + }); + } + + getPortals() { + // distract the user with jingling keys + this.setState({rows: [getSpinnerRow(4)], isEmpty: false, isError: false}); + let params = { + pageSize: this.state.pageSize, + page: this.state.page + }; + if (this.state.textFilter) { + params['filter'] = ['title%' + this.state.textFilter]; + } + HttpClient.get([Settings.serverUrl, 'admin', 'portal'], params) + .then(response => HttpClient.handleResponse(response)) + .then(data => this.setState({ + rows: data.portals.map((portal) => portalToRow(portal, this.onDeleteClick)), + portals: data.portals, + page: data.pagination.page, + pageSize: data.pagination.pageSize, + totalItems: data.pagination.totalItems, + totalPages: data.pagination.totalPages, + isEmpty: data.pagination.totalItems === 0 + })) + .catch((error) => { + console.error('Error fetching portals data:', error); + this.setState({rows: [], isEmpty: false, isError: true}); + }); + } + + onDeleteClick = (portalId) => { + const selectedPortal = this.state.portals.find((portal) => portal.id === portalId); + this.setState({selectedPortal: selectedPortal, isDeleteModalOpen: true}); + }; + + onDeleteModalClose = () => { + this.setState({isDeleteModalOpen: false}); + }; + + onModalDeleteClick = () => { + // spinner + HttpClient.delete([Settings.serverUrl, 'admin', 'portal', this.state.selectedPortal.id]) + .then(response => HttpClient.handleResponse(response)) + .then(() => { + this.getPortals(); + this.setState({isDeleteModalOpen: false}); + }); + } + + onTextChanged = (newValue) => { + this.setState({textFilter: newValue}, debounce(() => { + if (newValue.length >= 3 || newValue.length === 0) { + this.updateUrl(); + this.getPortals(); + } + })); + }; + + componentDidMount() { + this.getPortals(); + } + + render() { + document.title = 'Portals - Administration | Ibutsu'; + const { columns, rows, textFilter } = this.state; + const pagination = { + pageSize: this.state.pageSize, + page: this.state.page, + totalItems: this.state.totalItems + }; + const filters = [ + this.onTextChanged(newValue)} style={{height: "inherit"}} key="textFilter"/> + ]; + return ( + + + + + + + Portals + + + + + + + + + + + + + + + + + + + {this.state.isDeleting ? 'Deleting...' : 'Delete'} + , + + ]} + > + Are you sure you want to delete “{this.state.selectedPortal && this.state.selectedPortal.title}”? This cannot be undone! + + + ); + } +} diff --git a/frontend/src/portal.js b/frontend/src/portal.js new file mode 100644 index 00000000..11087c35 --- /dev/null +++ b/frontend/src/portal.js @@ -0,0 +1 @@ +// probably don't need this, just use app and handle selection from the masthead there? diff --git a/frontend/src/profile.js b/frontend/src/profile.js index 076e4386..df5b879e 100644 --- a/frontend/src/profile.js +++ b/frontend/src/profile.js @@ -1,49 +1,33 @@ 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 { UserProfile } from './pages/profile/user'; import { UserTokens } from './pages/profile/tokens'; -import { IbutsuPage } from './components'; import './app.css'; import ElementWrapper from './components/elementWrapper'; +import ProfilePage from './components/profile-page'; -export class Profile extends React.Component { - constructor(props) { - super(props); - this.eventEmitter = new EventEmitter(); - } - - render() { - const navigation = ( - - ); - - return ( - - - - } /> - } /> - - - - ); - } +const Profile = () => { + // TODO useEffect + const eventEmitter = new EventEmitter(); + + return ( + + }> + } /> + } /> + }/> + + + ); } + +Profile.propTypes = { + +}; + +export default Profile; diff --git a/frontend/src/report-builder.js b/frontend/src/report-builder.js index 9481f428..36885878 100644 --- a/frontend/src/report-builder.js +++ b/frontend/src/report-builder.js @@ -31,10 +31,10 @@ import { toTitleCase, parseFilter, getSpinnerRow, - getActiveProject, } from './utilities'; import { DownloadButton, FilterTable } from './components'; import { OPERATIONS } from './constants'; +import { IbutsuContext } from './services/context'; function reportToRow(report) { @@ -64,6 +64,7 @@ function reportToRow(report) { } export class ReportBuilder extends React.Component { + static contextType = IbutsuContext; static propTypes = { location: PropTypes.object, eventEmitter: PropTypes.object @@ -139,9 +140,9 @@ export class ReportBuilder extends React.Component { pageSize: this.state.pageSize, page: this.state.page }; - const project = getActiveProject(); - if (project) { - params['project'] = project.id; + const { primaryObject } = this.context; + if (primaryObject) { + params['project'] = primaryObject.id; } HttpClient.get([Settings.serverUrl, 'report'], params) .then(response => HttpClient.handleResponse(response)) @@ -167,14 +168,14 @@ export class ReportBuilder extends React.Component { } onRunReportClick = () => { - const project = getActiveProject(); + const { primaryObject } = this.context; let params = { type: this.state.reportType, filter: this.state.reportFilter, source: this.state.reportSource }; - if (project) { - params['project'] = project.id; + if (primaryObject) { + params['project'] = primaryObject.id; } HttpClient.post([Settings.serverUrl, 'report'], params).then(() => this.getReports()); }; diff --git a/frontend/src/result-list.js b/frontend/src/result-list.js index fdf9496b..e2dc7cc0 100644 --- a/frontend/src/result-list.js +++ b/frontend/src/result-list.js @@ -28,7 +28,6 @@ import { HttpClient } from './services/http'; import { Settings } from './settings'; import { buildParams, - getActiveProject, getFilterMode, getOperationMode, getOperationsFromField, @@ -38,8 +37,11 @@ import { } from './utilities'; import { FilterTable, MultiValueInput } from './components'; import { OPERATIONS, RESULT_FIELDS } from './constants'; +import { IbutsuContext } from './services/context'; export class ResultList extends React.Component { + static contextType = IbutsuContext; + static propTypes = { location: PropTypes.object, navigate: PropTypes.func, @@ -356,7 +358,7 @@ export class ResultList extends React.Component { let params = buildParams(this.state.filters); params.push('page=' + this.state.page); params.push('pageSize=' + this.state.pageSize); - this.props.navigate('/results?' + params.join('&')) + this.props.navigate('results?' + params.join('&')) } setPage = (_event, pageNumber) => { @@ -378,9 +380,9 @@ export class ResultList extends React.Component { this.setState({rows: [getSpinnerRow(5)], isEmpty: false, isError: false}); 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'] diff --git a/frontend/src/result.js b/frontend/src/result.js index 68a0b01e..e09b3d23 100644 --- a/frontend/src/result.js +++ b/frontend/src/result.js @@ -25,7 +25,7 @@ export class Result extends React.Component { this.state = { isResultValid: false, testResult: null, - id: props.params.id + id: props.params.result_id }; } diff --git a/frontend/src/run-list.js b/frontend/src/run-list.js index 9a31b812..4e4fbc0f 100644 --- a/frontend/src/run-list.js +++ b/frontend/src/run-list.js @@ -28,7 +28,6 @@ import { Settings } from './settings'; import { buildBadge, buildParams, - getActiveProject, getFilterMode, getOperationMode, getOperationsFromField, @@ -38,6 +37,7 @@ import { } from './utilities'; import { MultiValueInput, FilterTable, RunSummary } from './components'; import { OPERATIONS, RUN_FIELDS } from './constants'; +import { IbutsuContext } from './services/context'; function runToRow(run, filterFunc) { @@ -75,16 +75,18 @@ function runToRow(run, filterFunc) { } return { "cells": [ - {title: {run.id} {badges}}, + {title: {run.id} {badges}}, {title: round(run.duration) + 's'}, {title: }, {title: created.toLocaleString()}, - {title: See results } + {title: See results } ] }; } export class RunList extends React.Component { + static contextType = IbutsuContext; + static propTypes = { location: PropTypes.object, navigate: PropTypes.func, @@ -136,8 +138,8 @@ export class RunList extends React.Component { isBoolOpen: false, }; this.params = new URLSearchParams(props.location.search); - props.eventEmitter.on('projectChange', () => { - this.getRuns(); + props.eventEmitter.on('projectChange', (value) => { + this.getRuns(value); }); } @@ -265,7 +267,6 @@ export class RunList extends React.Component { this.setState({filters: filters, page: 1}, callback); } - setFilter = (field, value) => { this.updateFilters(field, 'eq', value, () => { this.updateUrl(); @@ -280,7 +281,6 @@ export class RunList extends React.Component { }); } - removeFilter = id => { this.updateFilters(id, null, null, () => { this.updateUrl(); @@ -309,14 +309,15 @@ export class RunList extends React.Component { }); } - getRuns() { + getRuns = (handledOject = null) => { // First, show a spinner this.setState({rows: [getSpinnerRow(5)], isEmpty: false, isError: false}); 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; + const targetObject = handledOject ?? primaryObject; + if (targetObject) { + filters['project_id'] = {'val': targetObject.id, 'op': 'eq'}; } else if (Object.prototype.hasOwnProperty.call(filters, 'project_id')) { delete filters['project_id'] @@ -346,7 +347,7 @@ export class RunList extends React.Component { console.error('Error fetching run data:', error); this.setState({rows: [], isEmpty: false, isError: true}); }); - } + }; clearFilters = () => { this.setState({ @@ -358,10 +359,8 @@ export class RunList extends React.Component { textFilter: '', inValues: [], boolSelection: null, - }, function () { - this.updateUrl(); - this.getRuns(); }); + this.updateUrl(); }; componentDidMount() { diff --git a/frontend/src/run.js b/frontend/src/run.js index 8a076821..c16f70a1 100644 --- a/frontend/src/run.js +++ b/frontend/src/run.js @@ -114,7 +114,7 @@ export class Run extends React.Component { super(props); this.state = { run: MockRun, - id: props.params.id, + id: props.params.run_id, testResult: null, columns: ['Test', 'Run', 'Result', 'Duration', 'Started'], rows: [getSpinnerRow(5)], @@ -236,6 +236,7 @@ export class Run extends React.Component { } getRunArtifacts() { + if (!this.state.id) {return;} HttpClient.get([Settings.serverUrl, 'artifact'], {runId: this.state.id}) .then(response => HttpClient.handleResponse(response)) .then(data => { @@ -356,6 +357,7 @@ export class Run extends React.Component { } getRun() { + if (!this.state.id) {return;} HttpClient.get([Settings.serverUrl, 'run', this.state.id]) .then(response => { response = HttpClient.handleResponse(response, 'response'); @@ -522,7 +524,7 @@ export class Run extends React.Component { {!this.state.isRunValid && - + } {this.state.isRunValid && @@ -733,7 +735,7 @@ export class Run 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..44f1d097 100644 --- a/frontend/src/utilities.js +++ b/frontend/src/utilities.js @@ -32,6 +32,8 @@ import { import { ClassificationDropdown } from './components'; + + export function getDateString() { return String((new Date()).getTime()); } @@ -200,14 +202,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 +249,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 +284,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,17 +403,11 @@ export function getOperationsFromField(field) { return operations; } -export function getActiveProject() { - let project = localStorage.getItem('project'); - if (project) { - project = JSON.parse(project); - } - return project; -} - +// TODO remove, moved to react routing params and context export function clearActiveProject() { localStorage.removeItem('project'); } +// TODO remove, moved to react routing params and context export function getActiveDashboard() { let dashboard = localStorage.getItem('dashboard'); @@ -420,6 +416,7 @@ export function getActiveDashboard() { } return dashboard; } +// TODO remove, moved to react routing params and context export function clearActiveDashboard() { localStorage.removeItem('dashboard'); @@ -527,6 +524,7 @@ export function debounce(func, timeout = 500) { }; } +// TODO remove, move to AppContext export function getTheme() { return localStorage.getItem('theme'); } 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/frontend/yarn.lock b/frontend/yarn.lock index 037111a5..c1948acd 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -39,26 +39,26 @@ "@babel/highlight" "^7.24.7" picocolors "^1.0.0" -"@babel/compat-data@^7.22.6", "@babel/compat-data@^7.24.7": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.24.7.tgz#d23bbea508c3883ba8251fb4164982c36ea577ed" - integrity sha512-qJzAIcv03PyaWqxRgO4mSU3lihncDT296vnyuE2O8uA4w3UHWI4S3hgeZd1L8W1Bft40w9JxJ2b412iDUFFRhw== +"@babel/compat-data@^7.22.6", "@babel/compat-data@^7.24.8": + version "7.24.9" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.24.9.tgz#53eee4e68f1c1d0282aa0eb05ddb02d033fc43a0" + integrity sha512-e701mcfApCJqMMueQI0Fb68Amflj83+dvAvHawoBpAz+GDjCIyGHzNwnefjsWJ3xiYAqqiQFoWbspGYBdb2/ng== "@babel/core@^7.1.0", "@babel/core@^7.11.1", "@babel/core@^7.12.3", "@babel/core@^7.16.0", "@babel/core@^7.24.7", "@babel/core@^7.7.2", "@babel/core@^7.8.0": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.24.7.tgz#b676450141e0b52a3d43bc91da86aa608f950ac4" - integrity sha512-nykK+LEK86ahTkX/3TgauT0ikKoNCfKHEaZYTUVupJdTLzGNvrblu4u6fa7DhZONAltdf8e662t/abY8idrd/g== + version "7.24.9" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.24.9.tgz#dc07c9d307162c97fa9484ea997ade65841c7c82" + integrity sha512-5e3FI4Q3M3Pbr21+5xJwCv6ZT6KmGkI0vw3Tozy5ODAQFTIWe37iT8Cr7Ice2Ntb+M3iSKCEWMB1MBgKrW3whg== dependencies: "@ampproject/remapping" "^2.2.0" "@babel/code-frame" "^7.24.7" - "@babel/generator" "^7.24.7" - "@babel/helper-compilation-targets" "^7.24.7" - "@babel/helper-module-transforms" "^7.24.7" - "@babel/helpers" "^7.24.7" - "@babel/parser" "^7.24.7" + "@babel/generator" "^7.24.9" + "@babel/helper-compilation-targets" "^7.24.8" + "@babel/helper-module-transforms" "^7.24.9" + "@babel/helpers" "^7.24.8" + "@babel/parser" "^7.24.8" "@babel/template" "^7.24.7" - "@babel/traverse" "^7.24.7" - "@babel/types" "^7.24.7" + "@babel/traverse" "^7.24.8" + "@babel/types" "^7.24.9" convert-source-map "^2.0.0" debug "^4.1.0" gensync "^1.0.0-beta.2" @@ -66,25 +66,25 @@ semver "^6.3.1" "@babel/eslint-parser@^7.16.3", "@babel/eslint-parser@^7.24.7": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/eslint-parser/-/eslint-parser-7.24.7.tgz#27ebab1a1ec21f48ae191a8aaac5b82baf80d9c7" - integrity sha512-SO5E3bVxDuxyNxM5agFv480YA2HO6ohZbGxbazZdIk3KQOPOGVNw6q78I9/lbviIf95eq6tPozeYnJLbjnC8IA== + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/eslint-parser/-/eslint-parser-7.24.8.tgz#bc655255fa4ded3694cc10ef3dbea6d69639c831" + integrity sha512-nYAikI4XTGokU2QX7Jx+v4rxZKhKivaQaREZjuW3mrJrbdWJ5yUfohnoUULge+zEEaKjPYNxhoRgUKktjXtbwA== dependencies: "@nicolo-ribaudo/eslint-scope-5-internals" "5.1.1-v1" eslint-visitor-keys "^2.1.0" semver "^6.3.1" -"@babel/generator@^7.24.7", "@babel/generator@^7.7.2": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.24.7.tgz#1654d01de20ad66b4b4d99c135471bc654c55e6d" - integrity sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA== +"@babel/generator@^7.24.8", "@babel/generator@^7.24.9", "@babel/generator@^7.7.2": + version "7.24.10" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.24.10.tgz#a4ab681ec2a78bbb9ba22a3941195e28a81d8e76" + integrity sha512-o9HBZL1G2129luEUlG1hB4N/nlYNWHnpwlND9eOMclRqqu1YDy2sSYVCFUZwl8I1Gxh+QSRrP2vD7EpUmFVXxg== dependencies: - "@babel/types" "^7.24.7" + "@babel/types" "^7.24.9" "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.25" jsesc "^2.5.1" -"@babel/helper-annotate-as-pure@^7.24.7": +"@babel/helper-annotate-as-pure@^7.18.6", "@babel/helper-annotate-as-pure@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.24.7.tgz#5373c7bc8366b12a033b4be1ac13a206c6656aab" integrity sha512-BaDeOonYvhdKw+JoMVkAixAAJzG2jVPIwWoKBPdYuY9b452e2rPuI9QPYh3KpofZ3pW2akOmwZLOiOsHMiqRAg== @@ -107,26 +107,26 @@ "@babel/helper-hoist-variables" "^7.12.13" "@babel/types" "^7.12.13" -"@babel/helper-compilation-targets@^7.22.6", "@babel/helper-compilation-targets@^7.24.7": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.24.7.tgz#4eb6c4a80d6ffeac25ab8cd9a21b5dfa48d503a9" - integrity sha512-ctSdRHBi20qWOfy27RUb4Fhp07KSJ3sXcuSvTrXrc4aG8NSYDo1ici3Vhg9bg69y5bj0Mr1lh0aeEgTvc12rMg== +"@babel/helper-compilation-targets@^7.22.6", "@babel/helper-compilation-targets@^7.24.7", "@babel/helper-compilation-targets@^7.24.8": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.24.8.tgz#b607c3161cd9d1744977d4f97139572fe778c271" + integrity sha512-oU+UoqCHdp+nWVDkpldqIQL/i/bvAv53tRqLG/s+cOXxe66zOYLU7ar/Xs3LdmBihrUMEUhwu6dMZwbNOYDwvw== dependencies: - "@babel/compat-data" "^7.24.7" - "@babel/helper-validator-option" "^7.24.7" - browserslist "^4.22.2" + "@babel/compat-data" "^7.24.8" + "@babel/helper-validator-option" "^7.24.8" + browserslist "^4.23.1" lru-cache "^5.1.1" semver "^6.3.1" -"@babel/helper-create-class-features-plugin@^7.18.6", "@babel/helper-create-class-features-plugin@^7.24.7": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.7.tgz#2eaed36b3a1c11c53bdf80d53838b293c52f5b3b" - integrity sha512-kTkaDl7c9vO80zeX1rJxnuRpEsD5tA81yh11X1gQo+PhSti3JS+7qeZo9U4RHobKRiFPKaGK3svUAeb8D0Q7eg== +"@babel/helper-create-class-features-plugin@^7.18.6", "@babel/helper-create-class-features-plugin@^7.21.0", "@babel/helper-create-class-features-plugin@^7.24.7", "@babel/helper-create-class-features-plugin@^7.24.8": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.8.tgz#47f546408d13c200c0867f9d935184eaa0851b09" + integrity sha512-4f6Oqnmyp2PP3olgUMmOwC3akxSm5aBYraQ6YDdKy7NcAMkDECHWG0DEnV6M2UAkERgIBhYt8S27rURPg7SxWA== dependencies: "@babel/helper-annotate-as-pure" "^7.24.7" "@babel/helper-environment-visitor" "^7.24.7" "@babel/helper-function-name" "^7.24.7" - "@babel/helper-member-expression-to-functions" "^7.24.7" + "@babel/helper-member-expression-to-functions" "^7.24.8" "@babel/helper-optimise-call-expression" "^7.24.7" "@babel/helper-replace-supers" "^7.24.7" "@babel/helper-skip-transparent-expression-wrappers" "^7.24.7" @@ -175,13 +175,13 @@ dependencies: "@babel/types" "^7.24.7" -"@babel/helper-member-expression-to-functions@^7.24.7": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.24.7.tgz#67613d068615a70e4ed5101099affc7a41c5225f" - integrity sha512-LGeMaf5JN4hAT471eJdBs/GK1DoYIJ5GCtZN/EsL6KUiiDZOvO/eKE11AMZJa2zP4zk4qe9V2O/hxAmkRc8p6w== +"@babel/helper-member-expression-to-functions@^7.24.7", "@babel/helper-member-expression-to-functions@^7.24.8": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.24.8.tgz#6155e079c913357d24a4c20480db7c712a5c3fb6" + integrity sha512-LABppdt+Lp/RlBxqrh4qgf1oEH/WxdzQNDJIu5gC/W1GyvPVrOBiItmmM8wan2fm4oYqFuFfkXmlGpLQhPY8CA== dependencies: - "@babel/traverse" "^7.24.7" - "@babel/types" "^7.24.7" + "@babel/traverse" "^7.24.8" + "@babel/types" "^7.24.8" "@babel/helper-module-imports@^7.10.4", "@babel/helper-module-imports@^7.24.7": version "7.24.7" @@ -191,10 +191,10 @@ "@babel/traverse" "^7.24.7" "@babel/types" "^7.24.7" -"@babel/helper-module-transforms@^7.24.7": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.24.7.tgz#31b6c9a2930679498db65b685b1698bfd6c7daf8" - integrity sha512-1fuJEwIrp+97rM4RWdO+qrRsZlAeL1lQJoPqtCYWv0NL115XM93hIH4CSRln2w52SqvmY5hqdtauB6QFCDiZNQ== +"@babel/helper-module-transforms@^7.24.7", "@babel/helper-module-transforms@^7.24.8", "@babel/helper-module-transforms@^7.24.9": + version "7.24.9" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.24.9.tgz#e13d26306b89eea569180868e652e7f514de9d29" + integrity sha512-oYbh+rtFKj/HwBQkFlUzvcybzklmVdVV3UU+mN7n2t/q3yGHbuVdNxyFvSBO1tfvjyArpHNcWMAzsSPdyI46hw== dependencies: "@babel/helper-environment-visitor" "^7.24.7" "@babel/helper-module-imports" "^7.24.7" @@ -209,10 +209,10 @@ dependencies: "@babel/types" "^7.24.7" -"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.20.2", "@babel/helper-plugin-utils@^7.22.5", "@babel/helper-plugin-utils@^7.24.7", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.7.tgz#98c84fe6fe3d0d3ae7bfc3a5e166a46844feb2a0" - integrity sha512-Rq76wjt7yz9AAc1KnlRKNAi/dMSVWgDRx43FHoJEbcYU6xOWaE2dVPwcdTukJrjxS65GITyfbvEYHvkirZ6uEg== +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.20.2", "@babel/helper-plugin-utils@^7.22.5", "@babel/helper-plugin-utils@^7.24.7", "@babel/helper-plugin-utils@^7.24.8", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.8.tgz#94ee67e8ec0e5d44ea7baeb51e571bd26af07878" + integrity sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg== "@babel/helper-remap-async-to-generator@^7.24.7": version "7.24.7" @@ -255,20 +255,20 @@ dependencies: "@babel/types" "^7.24.7" -"@babel/helper-string-parser@^7.24.7": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz#4d2d0f14820ede3b9807ea5fc36dfc8cd7da07f2" - integrity sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg== +"@babel/helper-string-parser@^7.24.8": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz#5b3329c9a58803d5df425e5785865881a81ca48d" + integrity sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ== "@babel/helper-validator-identifier@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz#75b889cfaf9e35c2aaf42cf0d72c8e91719251db" integrity sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w== -"@babel/helper-validator-option@^7.24.7": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.24.7.tgz#24c3bb77c7a425d1742eec8fb433b5a1b38e62f6" - integrity sha512-yy1/KvjhV/ZCL+SM7hBrvnZJ3ZuT9OuZgIJAGpPEToANvc3iM6iDvBnRjtElWibHU6n8/LPR/EjX9EtIEYO3pw== +"@babel/helper-validator-option@^7.24.7", "@babel/helper-validator-option@^7.24.8": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz#3725cdeea8b480e86d34df15304806a06975e33d" + integrity sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q== "@babel/helper-wrap-function@^7.24.7": version "7.24.7" @@ -280,13 +280,13 @@ "@babel/traverse" "^7.24.7" "@babel/types" "^7.24.7" -"@babel/helpers@^7.24.7": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.24.7.tgz#aa2ccda29f62185acb5d42fb4a3a1b1082107416" - integrity sha512-NlmJJtvcw72yRJRcnCmGvSi+3jDEg8qFu3z0AFoymmzLx5ERVWyzd9kVXr7Th9/8yIJi2Zc6av4Tqz3wFs8QWg== +"@babel/helpers@^7.24.8": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.24.8.tgz#2820d64d5d6686cca8789dd15b074cd862795873" + integrity sha512-gV2265Nkcz7weJJfvDoAEVzC1e2OTDpkGbEsebse8koXUJUXPsCMi7sRo/+SPMuMZ9MtUPnGwITTnQnU5YjyaQ== dependencies: "@babel/template" "^7.24.7" - "@babel/types" "^7.24.7" + "@babel/types" "^7.24.8" "@babel/highlight@^7.10.4", "@babel/highlight@^7.24.7": version "7.24.7" @@ -298,10 +298,10 @@ js-tokens "^4.0.0" picocolors "^1.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.24.7": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.7.tgz#9a5226f92f0c5c8ead550b750f5608e766c8ce85" - integrity sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw== +"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.24.7", "@babel/parser@^7.24.8": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.8.tgz#58a4dbbcad7eb1d48930524a3fd93d93e9084c6f" + integrity sha512-WzfbgXOkGzZiXXCqk43kKwZjzwx4oulxZi3nq2TYL9mOjQv6kYwul9mz6ID36njuL7Xkp6nJEfok848Zj10j/w== "@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.24.7": version "7.24.7" @@ -390,6 +390,16 @@ resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz#7844f9289546efa9febac2de4cfe358a050bd703" integrity sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w== +"@babel/plugin-proposal-private-property-in-object@^7.21.11": + version "7.21.11" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.11.tgz#69d597086b6760c4126525cfa154f34631ff272c" + integrity sha512-0QZ8qP/3RLDVBwBFoWAwCtgcDZJVwA5LUJRZU8x2YFfKNuFq161wK3cuGrALu5yiPu+vzwTAg/sMWVNeWeNyaw== + dependencies: + "@babel/helper-annotate-as-pure" "^7.18.6" + "@babel/helper-create-class-features-plugin" "^7.21.0" + "@babel/helper-plugin-utils" "^7.20.2" + "@babel/plugin-syntax-private-property-in-object" "^7.14.5" + "@babel/plugin-syntax-async-generators@^7.8.4": version "7.8.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" @@ -609,16 +619,16 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/plugin-syntax-class-static-block" "^7.14.5" -"@babel/plugin-transform-classes@^7.24.7": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.24.7.tgz#4ae6ef43a12492134138c1e45913f7c46c41b4bf" - integrity sha512-CFbbBigp8ln4FU6Bpy6g7sE8B/WmCmzvivzUC6xDAdWVsjYTXijpuuGJmYkAaoWAzcItGKT3IOAbxRItZ5HTjw== +"@babel/plugin-transform-classes@^7.24.8": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.24.8.tgz#ad23301fe5bc153ca4cf7fb572a9bc8b0b711cf7" + integrity sha512-VXy91c47uujj758ud9wx+OMgheXm4qJfyhj1P18YvlrQkNOSrwsteHk+EFS3OMGfhMhpZa0A+81eE7G4QC+3CA== dependencies: "@babel/helper-annotate-as-pure" "^7.24.7" - "@babel/helper-compilation-targets" "^7.24.7" + "@babel/helper-compilation-targets" "^7.24.8" "@babel/helper-environment-visitor" "^7.24.7" "@babel/helper-function-name" "^7.24.7" - "@babel/helper-plugin-utils" "^7.24.7" + "@babel/helper-plugin-utils" "^7.24.8" "@babel/helper-replace-supers" "^7.24.7" "@babel/helper-split-export-declaration" "^7.24.7" globals "^11.1.0" @@ -631,12 +641,12 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/template" "^7.24.7" -"@babel/plugin-transform-destructuring@^7.24.7": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.24.7.tgz#a097f25292defb6e6cc16d6333a4cfc1e3c72d9e" - integrity sha512-19eJO/8kdCQ9zISOf+SEUJM/bAUIsvY3YDnXZTupUCQ8LgrWnsG/gFB9dvXqdXnRXMAM8fvt7b0CBKQHNGy1mw== +"@babel/plugin-transform-destructuring@^7.24.8": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.24.8.tgz#c828e814dbe42a2718a838c2a2e16a408e055550" + integrity sha512-36e87mfY8TnRxc7yc6M9g9gOB7rKgSahqkIKwLpz4Ppk2+zC2Cy1is0uwtuSG6AE4zlTOUa+7JGz9jCJGLqQFQ== dependencies: - "@babel/helper-plugin-utils" "^7.24.7" + "@babel/helper-plugin-utils" "^7.24.8" "@babel/plugin-transform-dotall-regex@^7.24.7": version "7.24.7" @@ -740,13 +750,13 @@ "@babel/helper-module-transforms" "^7.24.7" "@babel/helper-plugin-utils" "^7.24.7" -"@babel/plugin-transform-modules-commonjs@^7.24.7": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.24.7.tgz#9fd5f7fdadee9085886b183f1ad13d1ab260f4ab" - integrity sha512-iFI8GDxtevHJ/Z22J5xQpVqFLlMNstcLXh994xifFwxxGslr2ZXXLWgtBeLctOD63UFDArdvN6Tg8RFw+aEmjQ== +"@babel/plugin-transform-modules-commonjs@^7.24.7", "@babel/plugin-transform-modules-commonjs@^7.24.8": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.24.8.tgz#ab6421e564b717cb475d6fff70ae7f103536ea3c" + integrity sha512-WHsk9H8XxRs3JXKWFiqtQebdh9b/pTk4EgueygFzYlTKAg0Ud985mSevdNjdXdFBATSKVJGQXP1tv6aGbssLKA== dependencies: - "@babel/helper-module-transforms" "^7.24.7" - "@babel/helper-plugin-utils" "^7.24.7" + "@babel/helper-module-transforms" "^7.24.8" + "@babel/helper-plugin-utils" "^7.24.8" "@babel/helper-simple-access" "^7.24.7" "@babel/plugin-transform-modules-systemjs@^7.24.7": @@ -824,12 +834,12 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" -"@babel/plugin-transform-optional-chaining@^7.24.7": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.24.7.tgz#b8f6848a80cf2da98a8a204429bec04756c6d454" - integrity sha512-tK+0N9yd4j+x/4hxF3F0e0fu/VdcxU18y5SevtyM/PCFlQvXbR0Zmlo2eBrKtVipGNFzpq56o8WsIIKcJFUCRQ== +"@babel/plugin-transform-optional-chaining@^7.24.7", "@babel/plugin-transform-optional-chaining@^7.24.8": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.24.8.tgz#bb02a67b60ff0406085c13d104c99a835cdf365d" + integrity sha512-5cTOLSMs9eypEy8JUVvIKOu6NgvbJMnpG62VpIHrTmROdQ+L5mDAaI40g25k5vXti55JWNX5jCkq3HZxXBQANw== dependencies: - "@babel/helper-plugin-utils" "^7.24.7" + "@babel/helper-plugin-utils" "^7.24.8" "@babel/helper-skip-transparent-expression-wrappers" "^7.24.7" "@babel/plugin-syntax-optional-chaining" "^7.8.3" @@ -961,21 +971,21 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" -"@babel/plugin-transform-typeof-symbol@^7.24.7": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.24.7.tgz#f074be466580d47d6e6b27473a840c9f9ca08fb0" - integrity sha512-VtR8hDy7YLB7+Pet9IarXjg/zgCMSF+1mNS/EQEiEaUPoFXCVsHG64SIxcaaI2zJgRiv+YmgaQESUfWAdbjzgg== +"@babel/plugin-transform-typeof-symbol@^7.24.8": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.24.8.tgz#383dab37fb073f5bfe6e60c654caac309f92ba1c" + integrity sha512-adNTUpDCVnmAE58VEqKlAA6ZBlNkMnWD0ZcW76lyNFN3MJniyGFZfNwERVk8Ap56MCnXztmDr19T4mPTztcuaw== dependencies: - "@babel/helper-plugin-utils" "^7.24.7" + "@babel/helper-plugin-utils" "^7.24.8" "@babel/plugin-transform-typescript@^7.24.7": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.24.7.tgz#b006b3e0094bf0813d505e0c5485679eeaf4a881" - integrity sha512-iLD3UNkgx2n/HrjBesVbYX6j0yqn/sJktvbtKKgcaLIQ4bTTQ8obAypc1VpyHPD2y4Phh9zHOaAt8e/L14wCpw== + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.24.8.tgz#c104d6286e04bf7e44b8cba1b686d41bad57eb84" + integrity sha512-CgFgtN61BbdOGCP4fLaAMOPkzWUh6yQZNMr5YSt8uz2cZSSiQONCQFWqsE4NeVfOIhqDOlS9CR3WD91FzMeB2Q== dependencies: "@babel/helper-annotate-as-pure" "^7.24.7" - "@babel/helper-create-class-features-plugin" "^7.24.7" - "@babel/helper-plugin-utils" "^7.24.7" + "@babel/helper-create-class-features-plugin" "^7.24.8" + "@babel/helper-plugin-utils" "^7.24.8" "@babel/plugin-syntax-typescript" "^7.24.7" "@babel/plugin-transform-unicode-escapes@^7.24.7": @@ -1010,14 +1020,14 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/preset-env@^7.11.0", "@babel/preset-env@^7.12.1", "@babel/preset-env@^7.16.4": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.24.7.tgz#ff067b4e30ba4a72f225f12f123173e77b987f37" - integrity sha512-1YZNsc+y6cTvWlDHidMBsQZrZfEFjRIo/BZCT906PMdzOyXtSLTgqGdrpcuTDCXyd11Am5uQULtDIcCfnTc8fQ== - dependencies: - "@babel/compat-data" "^7.24.7" - "@babel/helper-compilation-targets" "^7.24.7" - "@babel/helper-plugin-utils" "^7.24.7" - "@babel/helper-validator-option" "^7.24.7" + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.24.8.tgz#e0db94d7f17d6f0e2564e8d29190bc8cdacec2d1" + integrity sha512-vObvMZB6hNWuDxhSaEPTKCwcqkAIuDtE+bQGn4XMXne1DSLzFVY8Vmj1bm+mUQXYNN8NmaQEO+r8MMbzPr1jBQ== + dependencies: + "@babel/compat-data" "^7.24.8" + "@babel/helper-compilation-targets" "^7.24.8" + "@babel/helper-plugin-utils" "^7.24.8" + "@babel/helper-validator-option" "^7.24.8" "@babel/plugin-bugfix-firefox-class-in-computed-class-key" "^7.24.7" "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.24.7" "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.24.7" @@ -1048,9 +1058,9 @@ "@babel/plugin-transform-block-scoping" "^7.24.7" "@babel/plugin-transform-class-properties" "^7.24.7" "@babel/plugin-transform-class-static-block" "^7.24.7" - "@babel/plugin-transform-classes" "^7.24.7" + "@babel/plugin-transform-classes" "^7.24.8" "@babel/plugin-transform-computed-properties" "^7.24.7" - "@babel/plugin-transform-destructuring" "^7.24.7" + "@babel/plugin-transform-destructuring" "^7.24.8" "@babel/plugin-transform-dotall-regex" "^7.24.7" "@babel/plugin-transform-duplicate-keys" "^7.24.7" "@babel/plugin-transform-dynamic-import" "^7.24.7" @@ -1063,7 +1073,7 @@ "@babel/plugin-transform-logical-assignment-operators" "^7.24.7" "@babel/plugin-transform-member-expression-literals" "^7.24.7" "@babel/plugin-transform-modules-amd" "^7.24.7" - "@babel/plugin-transform-modules-commonjs" "^7.24.7" + "@babel/plugin-transform-modules-commonjs" "^7.24.8" "@babel/plugin-transform-modules-systemjs" "^7.24.7" "@babel/plugin-transform-modules-umd" "^7.24.7" "@babel/plugin-transform-named-capturing-groups-regex" "^7.24.7" @@ -1073,7 +1083,7 @@ "@babel/plugin-transform-object-rest-spread" "^7.24.7" "@babel/plugin-transform-object-super" "^7.24.7" "@babel/plugin-transform-optional-catch-binding" "^7.24.7" - "@babel/plugin-transform-optional-chaining" "^7.24.7" + "@babel/plugin-transform-optional-chaining" "^7.24.8" "@babel/plugin-transform-parameters" "^7.24.7" "@babel/plugin-transform-private-methods" "^7.24.7" "@babel/plugin-transform-private-property-in-object" "^7.24.7" @@ -1084,7 +1094,7 @@ "@babel/plugin-transform-spread" "^7.24.7" "@babel/plugin-transform-sticky-regex" "^7.24.7" "@babel/plugin-transform-template-literals" "^7.24.7" - "@babel/plugin-transform-typeof-symbol" "^7.24.7" + "@babel/plugin-transform-typeof-symbol" "^7.24.8" "@babel/plugin-transform-unicode-escapes" "^7.24.7" "@babel/plugin-transform-unicode-property-regex" "^7.24.7" "@babel/plugin-transform-unicode-regex" "^7.24.7" @@ -1093,7 +1103,7 @@ babel-plugin-polyfill-corejs2 "^0.4.10" babel-plugin-polyfill-corejs3 "^0.10.4" babel-plugin-polyfill-regenerator "^0.6.1" - core-js-compat "^3.31.0" + core-js-compat "^3.37.1" semver "^6.3.1" "@babel/preset-flow@^7.24.7": @@ -1142,10 +1152,10 @@ resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310" integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA== -"@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.16.3", "@babel/runtime@^7.23.2", "@babel/runtime@^7.8.4": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.7.tgz#f4f0d5530e8dbdf59b3451b9b3e594b6ba082e12" - integrity sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw== +"@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.16.3", "@babel/runtime@^7.8.4": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.8.tgz#5d958c3827b13cc6d05e038c07fb2e5e3420d82e" + integrity sha512-5F7SDGs1T72ZczbRwbGO9lQi0NLjQxzl6i4lJxLxfW9U5UluCSyEJeniWvnhl3/euNiqQVbo8zruhsDfid0esA== dependencies: regenerator-runtime "^0.14.0" @@ -1158,28 +1168,28 @@ "@babel/parser" "^7.24.7" "@babel/types" "^7.24.7" -"@babel/traverse@^7.24.7", "@babel/traverse@^7.7.2": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.7.tgz#de2b900163fa741721ba382163fe46a936c40cf5" - integrity sha512-yb65Ed5S/QAcewNPh0nZczy9JdYXkkAbIsEo+P7BE7yO3txAY30Y/oPa3QkQ5It3xVG2kpKMg9MsdxZaO31uKA== +"@babel/traverse@^7.24.7", "@babel/traverse@^7.24.8", "@babel/traverse@^7.7.2": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.8.tgz#6c14ed5232b7549df3371d820fbd9abfcd7dfab7" + integrity sha512-t0P1xxAPzEDcEPmjprAQq19NWum4K0EQPjMwZQZbHt+GiZqvjCHjj755Weq1YRPVzBI+3zSfvScfpnuIecVFJQ== dependencies: "@babel/code-frame" "^7.24.7" - "@babel/generator" "^7.24.7" + "@babel/generator" "^7.24.8" "@babel/helper-environment-visitor" "^7.24.7" "@babel/helper-function-name" "^7.24.7" "@babel/helper-hoist-variables" "^7.24.7" "@babel/helper-split-export-declaration" "^7.24.7" - "@babel/parser" "^7.24.7" - "@babel/types" "^7.24.7" + "@babel/parser" "^7.24.8" + "@babel/types" "^7.24.8" debug "^4.3.1" globals "^11.1.0" -"@babel/types@^7.0.0", "@babel/types@^7.12.13", "@babel/types@^7.12.6", "@babel/types@^7.20.7", "@babel/types@^7.24.7", "@babel/types@^7.3.3", "@babel/types@^7.4.4": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.24.7.tgz#6027fe12bc1aa724cd32ab113fb7f1988f1f66f2" - integrity sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q== +"@babel/types@^7.0.0", "@babel/types@^7.12.13", "@babel/types@^7.12.6", "@babel/types@^7.20.7", "@babel/types@^7.24.7", "@babel/types@^7.24.8", "@babel/types@^7.24.9", "@babel/types@^7.3.3", "@babel/types@^7.4.4": + version "7.24.9" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.24.9.tgz#228ce953d7b0d16646e755acf204f4cf3d08cc73" + integrity sha512-xm8XrMKz0IlUdocVbYJe0Z9xEgidU7msskG8BbhnTPK/HZ2z/7FP7ykqPgrUH+C+r414mNfNWam1f2vqOjqjYQ== dependencies: - "@babel/helper-string-parser" "^7.24.7" + "@babel/helper-string-parser" "^7.24.8" "@babel/helper-validator-identifier" "^7.24.7" to-fast-properties "^2.0.0" @@ -1344,9 +1354,9 @@ eslint-visitor-keys "^3.3.0" "@eslint-community/regexpp@^4.4.0", "@eslint-community/regexpp@^4.6.1": - version "4.10.1" - resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.1.tgz#361461e5cb3845d874e61731c11cfedd664d83a0" - integrity sha512-Zm2NGpWELsQAD1xsJzGQpYfvICSsFkEpU0jxBjfdC6uNEWXcHnfs9hScFWtXVDVl+rBQJGrl4g1vcKIejpH9dA== + version "4.11.0" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.11.0.tgz#b0ffd0312b4a3fd2d6f77237e7248a5ad3a680ae" + integrity sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A== "@eslint/eslintrc@^0.4.3": version "0.4.3" @@ -1687,9 +1697,9 @@ "@jridgewell/trace-mapping" "^0.3.25" "@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14": - version "1.4.15" - resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" - integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== + version "1.5.0" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" + integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== "@jridgewell/trace-mapping@^0.3.20", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": version "0.3.25" @@ -1779,10 +1789,10 @@ victory-voronoi-container "^36.9.1" victory-zoom-container "^36.9.1" -"@patternfly/react-core@^5.2.3", "@patternfly/react-core@^5.3.3": - version "5.3.3" - resolved "https://registry.yarnpkg.com/@patternfly/react-core/-/react-core-5.3.3.tgz#06f589f5b0bb95dd231a0ca9379fa68d242899ab" - integrity sha512-qq3j0M+Vi+Xmd+a/MhRhGgjdRh9Hnm79iA+L935HwMIVDcIWRYp6Isib/Ha4+Jk+f3Qdl0RT3dBDvr/4m6OpVQ== +"@patternfly/react-core@^5.2.3", "@patternfly/react-core@^5.3.4": + version "5.3.4" + resolved "https://registry.yarnpkg.com/@patternfly/react-core/-/react-core-5.3.4.tgz#84f85d3528655134cf0bcdb096f82777f0dd69b6" + integrity sha512-zr2yeilIoFp8MFOo0vNgI8XuM+P2466zHvy4smyRNRH2/but2WObqx7Wu4ftd/eBMYdNqmTeuXe6JeqqRqnPMQ== dependencies: "@patternfly/react-icons" "^5.3.2" "@patternfly/react-styles" "^5.3.1" @@ -1802,11 +1812,11 @@ integrity sha512-H6uBoFH3bJjD6PP75qZ4k+2TtF59vxf9sIVerPpwrGJcRgBZbvbMZCniSC3+S2LQ8DgXLnDvieq78jJzHz0hiA== "@patternfly/react-table@^5.2.4": - version "5.3.3" - resolved "https://registry.yarnpkg.com/@patternfly/react-table/-/react-table-5.3.3.tgz#866ec3d2d57d506199b6d53dbd7d01a98abb321a" - integrity sha512-uaRmsJABvVPH8gYTh+EUcDz61knIxe9qor/VGUYDLONYBL5G3IaltwG42IsJ9jShxiwFmIPy+QARPpaadTpv5w== + version "5.3.4" + resolved "https://registry.yarnpkg.com/@patternfly/react-table/-/react-table-5.3.4.tgz#f5e0ee5057cce7a54742ea9ca7f038aa9ca5e5ed" + integrity sha512-jGaiuo02scaC1HdGNHuYVRjtQCOB+vtvfbgS7nl1Y8ZcJ08wyUGhGSrEpNHfGAQ1XDSSoELAxj0cjOQwAAQw1A== dependencies: - "@patternfly/react-core" "^5.3.3" + "@patternfly/react-core" "^5.3.4" "@patternfly/react-icons" "^5.3.2" "@patternfly/react-styles" "^5.3.1" "@patternfly/react-tokens" "^5.3.1" @@ -1841,10 +1851,10 @@ resolved "https://registry.yarnpkg.com/@react-oauth/google/-/google-0.12.1.tgz#b76432c3a525e9afe076f787d2ded003fcc1bee9" integrity sha512-qagsy22t+7UdkYAiT5ZhfM4StXi9PPNvw0zuwNmabrWyMKddczMtBIOARflbaIj+wHiQjnMAsZmzsUYuXeyoSg== -"@remix-run/router@1.16.1": - version "1.16.1" - resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.16.1.tgz#73db3c48b975eeb06d0006481bde4f5f2d17d1cd" - integrity sha512-es2g3dq6Nb07iFxGk5GuHN20RwBZOsuDQN7izWIisUcv9r+d2C5jQxqmgkdebXgReWfiyUabcki6Fg77mSNrig== +"@remix-run/router@1.18.0": + version "1.18.0" + resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.18.0.tgz#20b033d1f542a100c1d57cfd18ecf442d1784732" + integrity sha512-L3jkqmqoSVBVKHfpGZmLrex0lxR5SucGA0sUfFzGctehw+S/ggL9L/0NnC5mw6P8HUWpFZ3nQw3cRApjjWx9Sw== "@rollup/plugin-babel@^5.2.0": version "5.3.1" @@ -2180,10 +2190,18 @@ "@types/eslint" "*" "@types/estree" "*" -"@types/eslint@*", "@types/eslint@^7.29.0 || ^8.4.1": - version "8.56.10" - resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.56.10.tgz#eb2370a73bf04a901eeba8f22595c7ee0f7eb58d" - integrity sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ== +"@types/eslint@*": + version "9.6.0" + resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-9.6.0.tgz#51d4fe4d0316da9e9f2c80884f2c20ed5fb022ff" + integrity sha512-gi6WQJ7cHRgZxtkQEoyHMppPjq9Kxo5Tjn2prSKDSmZrCz8TZ3jSRCeTJm+WoM+oB0WG37bRqLzaaU3q7JypGg== + dependencies: + "@types/estree" "*" + "@types/json-schema" "*" + +"@types/eslint@^7.29.0 || ^8.4.1": + version "8.56.11" + resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.56.11.tgz#e2ff61510a3b9454b3329fe7731e3b4c6f780041" + integrity sha512-sVBpJMf7UPo/wGecYOpk2aQya2VUGeHhe38WG7/mN5FufNSubf5VT9Uh9Uyp8/eLJpu1/tuhJ/qTo4mhSB4V4Q== dependencies: "@types/estree" "*" "@types/json-schema" "*" @@ -2199,9 +2217,9 @@ integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== "@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.33": - version "4.19.3" - resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.19.3.tgz#e469a13e4186c9e1c0418fb17be8bc8ff1b19a7a" - integrity sha512-KOzM7MhcBFlmnlr/fzISFF5vGWVSvN6fTd4T+ExOt08bA/dA5kpSzY52nMsI1KDFmUREpJelPYyuslLRSjjgCg== + version "4.19.5" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.19.5.tgz#218064e321126fcf9048d1ca25dd2465da55d9c6" + integrity sha512-y6W03tvrACO72aijJ5uF02FRq5cgDR9lUxddQ8vyF+GvmjJQqbzDcJngEjURc+ZsG31VI3hODNZJ2URj86pzmg== dependencies: "@types/node" "*" "@types/qs" "*" @@ -2272,9 +2290,9 @@ integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== "@types/lodash@^4.17.0": - version "4.17.5" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.5.tgz#e6c29b58e66995d57cd170ce3e2a61926d55ee04" - integrity sha512-MBIOHVZqVqgfro1euRDWX7OO0fBVUUMrN6Pwm8LQsz8cWhEpihlvR70ENj3f40j58TNxZaWv2ndSkInykNBBJw== + version "4.17.7" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.7.tgz#2f776bcb53adc9e13b2c0dfd493dfcbd7de43612" + integrity sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA== "@types/mime@^1": version "1.3.5" @@ -2289,9 +2307,9 @@ "@types/node" "*" "@types/node@*": - version "20.14.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.2.tgz#a5f4d2bcb4b6a87bffcaa717718c5a0f208f4a18" - integrity sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q== + version "20.14.11" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.11.tgz#09b300423343460455043ddd4d0ded6ac579b74b" + integrity sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA== dependencies: undici-types "~5.26.4" @@ -2414,9 +2432,9 @@ integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== "@types/ws@^8.5.5": - version "8.5.10" - resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.10.tgz#4acfb517970853fa6574a3a6886791d04a396787" - integrity sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A== + version "8.5.11" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.11.tgz#90ad17b3df7719ce3e6bc32f83ff954d38656508" + integrity sha512-4+q7P5h3SpJxaBft0Dzpbr6lmMaqh0Jr2tbhJZ/luAwvD7ohSCniYkwz/pLxuT2h0EOa6QADgJj1Ko+TzRfZ+w== dependencies: "@types/node" "*" @@ -2699,10 +2717,10 @@ acorn-globals@^6.0.0: acorn "^7.1.1" acorn-walk "^7.1.1" -acorn-import-assertions@^1.9.0: - version "1.9.0" - resolved "https://registry.yarnpkg.com/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz#507276249d684797c84e0734ef84860334cfb1ac" - integrity sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA== +acorn-import-attributes@^1.9.5: + version "1.9.5" + resolved "https://registry.yarnpkg.com/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz#7eb1557b1ba05ef18b5ed0ec67591bfab04688ef" + integrity sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ== acorn-jsx@^5.3.1, acorn-jsx@^5.3.2: version "5.3.2" @@ -2720,9 +2738,9 @@ acorn@^7.1.1, acorn@^7.4.0: integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== acorn@^8.2.4, acorn@^8.7.1, acorn@^8.8.2, acorn@^8.9.0: - version "8.11.3" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" - integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== + version "8.12.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.1.tgz#71616bdccbe25e27a54439e0046e89ca76df2248" + integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg== address@^1.0.1, address@^1.1.2: version "1.2.2" @@ -2782,14 +2800,14 @@ ajv@6.12.6, ajv@^6.10.0, ajv@^6.12.2, ajv@^6.12.4, ajv@^6.12.5: uri-js "^4.2.2" ajv@^8.0.0, ajv@^8.0.1, ajv@^8.6.0, ajv@^8.9.0: - version "8.16.0" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.16.0.tgz#22e2a92b94f005f7e0f9c9d39652ef0b8f6f0cb4" - integrity sha512-F0twR8U1ZU67JIEtekUcLkXkoO5mMMmgGD8sK/xUFzJ805jxHQl92hImFAqqXMyMYjSPOyUPAwHYhB72g5sTXw== + version "8.17.1" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6" + integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== dependencies: fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" json-schema-traverse "^1.0.0" require-from-string "^2.0.2" - uri-js "^4.4.1" ansi-align@^2.0.0: version "2.0.0" @@ -2899,20 +2917,13 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== -aria-query@5.1.3: +aria-query@5.1.3, aria-query@~5.1.3: version "5.1.3" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.1.3.tgz#19db27cd101152773631396f7a95a3b58c22c35e" integrity sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ== dependencies: deep-equal "^2.0.5" -aria-query@^5.3.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.0.tgz#650c569e41ad90b51b3d7df5e5eed1c7549c103e" - integrity sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A== - dependencies: - dequal "^2.0.3" - array-buffer-byte-length@^1.0.0, array-buffer-byte-length@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz#1e5583ec16763540a27ae52eed99ff899223568f" @@ -3012,17 +3023,7 @@ array.prototype.reduce@^1.0.6: es-object-atoms "^1.0.0" is-string "^1.0.7" -array.prototype.toreversed@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/array.prototype.toreversed/-/array.prototype.toreversed-1.1.2.tgz#b989a6bf35c4c5051e1dc0325151bf8088954eba" - integrity sha512-wwDCoT4Ck4Cz7sLtgUmzR5UV3YF5mFHUlbChCzZBQZ+0m2cl/DH3tKgvphv1nKgFsJ48oCSg6p91q2Vm0I/ZMA== - dependencies: - call-bind "^1.0.2" - define-properties "^1.2.0" - es-abstract "^1.22.1" - es-shim-unscopables "^1.0.0" - -array.prototype.tosorted@^1.1.3: +array.prototype.tosorted@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz#fe954678ff53034e717ea3352a03f0b0b86f7ffc" integrity sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA== @@ -3123,17 +3124,17 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.13.0.tgz#d9b802e9bb9c248d7be5f7f5ef178dc3684e9dcc" integrity sha512-3AungXC4I8kKsS9PuS4JH2nc+0bVY/mjgrephHTIi8fpEeGsTHBUJeosp0Wc1myYMElmD0B3Oc4XL/HVJ4PV2g== -axe-core@=4.7.0: - version "4.7.0" - resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.7.0.tgz#34ba5a48a8b564f67e103f0aa5768d76e15bbbbf" - integrity sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ== +axe-core@^4.9.1: + version "4.9.1" + resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.9.1.tgz#fcd0f4496dad09e0c899b44f6c4bb7848da912ae" + integrity sha512-QbUdXJVTpvUTHU7871ppZkdOLBeGUKBQWHkHrvN2V9IQWGMt61zf3B45BtzjxEJzYuj0JBjBZP/hmYS/R9pmAw== -axobject-query@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.2.1.tgz#39c378a6e3b06ca679f29138151e45b2b32da62a" - integrity sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg== +axobject-query@~3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.1.1.tgz#3b6e5c6d4e43ca7ba51c5babf99d22a9c68485e1" + integrity sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg== dependencies: - dequal "^2.0.3" + deep-equal "^2.0.5" babel-jest@^27.4.2, babel-jest@^27.5.1: version "27.5.1" @@ -3395,15 +3396,15 @@ browser-process-hrtime@^1.0.0: resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz#3c9b4b7d782c8121e56f10106d84c0d0ffc94626" integrity sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow== -browserslist@^4.0.0, browserslist@^4.18.1, browserslist@^4.21.10, browserslist@^4.21.4, browserslist@^4.22.2, browserslist@^4.23.0: - version "4.23.1" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.23.1.tgz#ce4af0534b3d37db5c1a4ca98b9080f985041e96" - integrity sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw== +browserslist@^4.0.0, browserslist@^4.18.1, browserslist@^4.21.10, browserslist@^4.21.4, browserslist@^4.23.0, browserslist@^4.23.1: + version "4.23.2" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.23.2.tgz#244fe803641f1c19c28c48c4b6ec9736eb3d32ed" + integrity sha512-qkqSyistMYdxAcw+CzbZwlBy8AGmS/eEWs+sEV5TnLRGDOL+C5M2EnH6tlZyg0YoAxGJAFKh61En9BR941GnHA== dependencies: - caniuse-lite "^1.0.30001629" - electron-to-chromium "^1.4.796" + caniuse-lite "^1.0.30001640" + electron-to-chromium "^1.4.820" node-releases "^2.0.14" - update-browserslist-db "^1.0.16" + update-browserslist-db "^1.1.0" bser@2.1.1: version "2.1.1" @@ -3504,10 +3505,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001599, caniuse-lite@^1.0.30001629: - version "1.0.30001632" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001632.tgz#964207b7cba5851701afb4c8afaf1448db3884b6" - integrity sha512-udx3o7yHJfUxMLkGohMlVHCvFvWmirKh9JAH/d7WOLPetlH+LTL5cocMZ0t7oZx/mdlOWXti97xLZWc8uURRHg== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001599, caniuse-lite@^1.0.30001640: + version "1.0.30001643" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001643.tgz#9c004caef315de9452ab970c3da71085f8241dbd" + integrity sha512-ERgWGNleEilSrHM6iUz/zJNSQTP8Mr21wDWpdgvRwcTXGAq6jMtOUPP4dqFPTdKqZ2wKTdtB+uucZ3MRpAUSmg== case-sensitive-paths-webpack-plugin@^2.4.0: version "2.4.0" @@ -3875,7 +3876,7 @@ cookie@0.6.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== -core-js-compat@^3.31.0, core-js-compat@^3.36.1: +core-js-compat@^3.36.1, core-js-compat@^3.37.1: version "3.37.1" resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.37.1.tgz#c844310c7852f4bdf49b8d339730b97e17ff09ee" integrity sha512-9TNiImhKvQqSUkOvk/mMRZzOANTiEVC7WaBNhHcKM7x+/5E1l5NvsysR19zuDQScE8k+kfQXWRN3AtS/eOSHpg== @@ -4327,9 +4328,9 @@ data-view-byte-offset@^1.0.0: is-data-view "^1.0.1" dayjs@^1.10.4: - version "1.11.11" - resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.11.tgz#dfe0e9d54c5f8b68ccf8ca5f72ac603e7e5ed59e" - integrity sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg== + version "1.11.12" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.12.tgz#5245226cc7f40a15bf52e0b99fd2a04669ccac1d" + integrity sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg== debug@2.6.9, debug@^2.6.0: version "2.6.9" @@ -4458,11 +4459,6 @@ depd@~1.1.2: resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== -dequal@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" - integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== - destroy@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" @@ -4677,10 +4673,10 @@ ejs@^3.1.6: dependencies: jake "^10.8.5" -electron-to-chromium@^1.4.796: - version "1.4.796" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.796.tgz#48dd6ff634b7f7df6313bd27aaa713f3af4a2b29" - integrity sha512-NglN/xprcM+SHD2XCli4oC6bWe6kHoytcyLKCWXmRL854F0qhPhaYgUswUsglnPxYaNQIg2uMY4BvaomIf3kLA== +electron-to-chromium@^1.4.820: + version "1.5.0" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.0.tgz#0d3123a9f09189b9c7ab4b5d6848d71b3c1fd0e8" + integrity sha512-Vb3xHHYnLseK8vlMJQKJYXJ++t4u1/qJ3vykuVrVjvdiOEhYyT1AuP4x03G8EnPmYvYOhe9T+dADTmthjRQMkA== emittery@^0.10.2: version "0.10.2" @@ -4719,10 +4715,10 @@ end-of-stream@^1.1.0: dependencies: once "^1.4.0" -enhanced-resolve@^5.16.0: - version "5.17.0" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.17.0.tgz#d037603789dd9555b89aaec7eb78845c49089bc5" - integrity sha512-dwDPwZL0dmye8Txp2gzFmA6sxALaSvdRDjPH0viLcKrtlOL3tw62nWWweVD1SdILDTJrbrL6tdWVN58Wo6U3eA== +enhanced-resolve@^5.17.0: + version "5.17.1" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz#67bfbbcc2f81d511be77d686a90267ef7f898a15" + integrity sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg== dependencies: graceful-fs "^4.2.4" tapable "^2.2.0" @@ -4795,7 +4791,7 @@ error-stack-parser@^2.0.6: dependencies: stackframe "^1.3.4" -es-abstract@^1.17.2, es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.23.0, es-abstract@^1.23.1, es-abstract@^1.23.2, es-abstract@^1.23.3: +es-abstract@^1.17.2, es-abstract@^1.17.5, es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.23.0, es-abstract@^1.23.1, es-abstract@^1.23.2, es-abstract@^1.23.3: version "1.23.3" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.23.3.tgz#8f0c5a35cd215312573c5a27c87dfd6c881a0aa0" integrity sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A== @@ -4879,7 +4875,7 @@ es-get-iterator@^1.1.3: isarray "^2.0.5" stop-iteration-iterator "^1.0.0" -es-iterator-helpers@^1.0.15, es-iterator-helpers@^1.0.19: +es-iterator-helpers@^1.0.19: version "1.0.19" resolved "https://registry.yarnpkg.com/es-iterator-helpers/-/es-iterator-helpers-1.0.19.tgz#117003d0e5fec237b4b5c08aded722e0c6d50ca8" integrity sha512-zoMwbCcH5hwUkKJkT8kDIBZSz9I6mVG//+lDCinLCGov4+r7NIy0ld8o03M0cJxl2spVf6ESYVS6/gpIfq1FFw== @@ -4900,9 +4896,9 @@ es-iterator-helpers@^1.0.15, es-iterator-helpers@^1.0.19: safe-array-concat "^1.1.2" es-module-lexer@^1.2.1: - version "1.5.3" - resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.5.3.tgz#25969419de9c0b1fbe54279789023e8a9a788412" - integrity sha512-i1gCgmR9dCl6Vil6UKPI/trA69s08g/syhiDK9TG0Nf1RJjjFI+AzoWW7sPufzkgYAn861skuCwJa0pIIHYxvg== + version "1.5.4" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.5.4.tgz#a8efec3a3da991e60efa6b633a7cad6ab8d26b78" + integrity sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw== es-object-atoms@^1.0.0: version "1.0.0" @@ -5066,26 +5062,26 @@ eslint-plugin-jest@^25.3.0: "@typescript-eslint/experimental-utils" "^5.0.0" eslint-plugin-jsx-a11y@^6.5.1: - version "6.8.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.8.0.tgz#2fa9c701d44fcd722b7c771ec322432857fcbad2" - integrity sha512-Hdh937BS3KdwwbBaKd5+PLCOmYY6U4f2h9Z2ktwtNKvIdIEu137rjYbcb9ApSbVJfWxANNuiKTD/9tOKjK9qOA== + version "6.9.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.9.0.tgz#67ab8ff460d4d3d6a0b4a570e9c1670a0a8245c8" + integrity sha512-nOFOCaJG2pYqORjK19lqPqxMO/JpvdCZdPtNdxY3kvom3jTvkAbOvQvD8wuD0G8BYR0IGAGYDlzqWJOh/ybn2g== dependencies: - "@babel/runtime" "^7.23.2" - aria-query "^5.3.0" - array-includes "^3.1.7" + aria-query "~5.1.3" + array-includes "^3.1.8" array.prototype.flatmap "^1.3.2" ast-types-flow "^0.0.8" - axe-core "=4.7.0" - axobject-query "^3.2.1" + axe-core "^4.9.1" + axobject-query "~3.1.1" damerau-levenshtein "^1.0.8" emoji-regex "^9.2.2" - es-iterator-helpers "^1.0.15" - hasown "^2.0.0" + es-iterator-helpers "^1.0.19" + hasown "^2.0.2" jsx-ast-utils "^3.3.5" language-tags "^1.0.9" minimatch "^3.1.2" - object.entries "^1.1.7" - object.fromentries "^2.0.7" + object.fromentries "^2.0.8" + safe-regex-test "^1.0.3" + string.prototype.includes "^2.0.0" eslint-plugin-react-hooks@^4.3.0: version "4.6.2" @@ -5093,28 +5089,28 @@ eslint-plugin-react-hooks@^4.3.0: integrity sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ== eslint-plugin-react@^7.27.1, eslint-plugin-react@^7.34.2: - version "7.34.2" - resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.34.2.tgz#2780a1a35a51aca379d86d29b9a72adc6bfe6b66" - integrity sha512-2HCmrU+/JNigDN6tg55cRDKCQWicYAPB38JGSFDQt95jDm8rrvSUo7YPkOIm5l6ts1j1zCvysNcasvfTMQzUOw== + version "7.35.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.35.0.tgz#00b1e4559896710e58af6358898f2ff917ea4c41" + integrity sha512-v501SSMOWv8gerHkk+IIQBkcGRGrO2nfybfj5pLxuJNFTPxxA3PSryhXTK+9pNbtkggheDdsC0E9Q8CuPk6JKA== dependencies: array-includes "^3.1.8" array.prototype.findlast "^1.2.5" array.prototype.flatmap "^1.3.2" - array.prototype.toreversed "^1.1.2" - array.prototype.tosorted "^1.1.3" + array.prototype.tosorted "^1.1.4" doctrine "^2.1.0" es-iterator-helpers "^1.0.19" estraverse "^5.3.0" + hasown "^2.0.2" jsx-ast-utils "^2.4.1 || ^3.0.0" minimatch "^3.1.2" object.entries "^1.1.8" object.fromentries "^2.0.8" - object.hasown "^1.1.4" object.values "^1.2.0" prop-types "^15.8.1" resolve "^2.0.0-next.5" semver "^6.3.1" string.prototype.matchall "^4.0.11" + string.prototype.repeat "^1.0.0" eslint-plugin-testing-library@^5.0.1: version "5.11.1" @@ -5291,9 +5287,9 @@ esprima@^4.0.0, esprima@^4.0.1: integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== esquery@^1.4.0, esquery@^1.4.2: - version "1.5.0" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b" - integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg== + version "1.6.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7" + integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== dependencies: estraverse "^5.1.0" @@ -5511,6 +5507,11 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== +fast-uri@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.0.1.tgz#cddd2eecfc83a71c1be2cc2ef2061331be8a7134" + integrity sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw== + fast-url-parser@1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/fast-url-parser/-/fast-url-parser-1.1.3.tgz#f4af3ea9f34d8a271cf58ad2b3759f431f0b318d" @@ -5673,9 +5674,9 @@ for-each@^0.3.3: is-callable "^1.1.3" foreground-child@^3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.1.1.tgz#1d173e776d75d2772fed08efe4a0de1ea1b12d0d" - integrity sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg== + version "3.2.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.2.1.tgz#767004ccf3a5b30df39bed90718bab43fe0a59f7" + integrity sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA== dependencies: cross-spawn "^7.0.0" signal-exit "^4.0.1" @@ -5903,14 +5904,15 @@ glob-to-regexp@^0.4.1: integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== glob@^10.3.10: - version "10.4.1" - resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.1.tgz#0cfb01ab6a6b438177bfe6a58e2576f6efe909c2" - integrity sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw== + version "10.4.5" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" + integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== dependencies: foreground-child "^3.1.0" jackspeak "^3.1.2" minimatch "^9.0.4" minipass "^7.1.2" + package-json-from-dist "^1.0.0" path-scurry "^1.11.1" glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: @@ -6308,9 +6310,9 @@ import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1: resolve-from "^4.0.0" import-local@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.1.0.tgz#b4479df8a5fd44f6cdce24070675676063c95cb4" - integrity sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg== + version "3.2.0" + resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.2.0.tgz#c3d5c745798c02a6f8b897726aba5100186ee260" + integrity sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA== dependencies: pkg-dir "^4.2.0" resolve-cwd "^3.0.0" @@ -6445,11 +6447,11 @@ is-ci@^3.0.0: ci-info "^3.2.0" is-core-module@^2.13.0, is-core-module@^2.13.1: - version "2.13.1" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.1.tgz#ad0d7532c6fea9da1ebdc82742d74525c6273384" - integrity sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw== + version "2.15.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.15.0.tgz#71c72ec5442ace7e76b306e9d48db361f22699ea" + integrity sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA== dependencies: - hasown "^2.0.0" + hasown "^2.0.2" is-data-view@^1.0.1: version "1.0.1" @@ -6743,18 +6745,18 @@ iterator.prototype@^1.1.2: set-function-name "^2.0.1" jackspeak@^3.1.2: - version "3.4.0" - resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.0.tgz#a75763ff36ad778ede6a156d8ee8b124de445b4a" - integrity sha512-JVYhQnN59LVPFCEcVa2C3CrEKYacvjRfqIQl+h8oi91aLYQVWRYbxjPcv1bUiUy/kLmQaANrYfNMCO3kuEDHfw== + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== dependencies: "@isaacs/cliui" "^8.0.2" optionalDependencies: "@pkgjs/parseargs" "^0.11.0" jake@^10.8.5: - version "10.9.1" - resolved "https://registry.yarnpkg.com/jake/-/jake-10.9.1.tgz#8dc96b7fcc41cb19aa502af506da4e1d56f5e62b" - integrity sha512-61btcOHNnLnsOdtLgA5efqQWjnSi/vow5HbI7HMdKKWqvrKR1bLK3BPlJn9gcSaP2ewuamUSMB5XEy76KUIS2w== + version "10.9.2" + resolved "https://registry.yarnpkg.com/jake/-/jake-10.9.2.tgz#6ae487e6a69afec3a5e167628996b59f35ae2b7f" + integrity sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA== dependencies: async "^3.2.3" chalk "^4.0.2" @@ -7244,9 +7246,9 @@ jest@^27.4.3: jest-cli "^27.5.1" jiti@^1.21.0: - version "1.21.3" - resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.3.tgz#b2adb07489d7629b344d59082bbedb8c21c5f755" - integrity sha512-uy2bNX5zQ+tESe+TiC7ilGRz8AtRGmnJH55NC5S0nSUjvvvM2hJHmefHErugGXN4pNv4Qx7vLsnNw9qJ9mtIsw== + version "1.21.6" + resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.6.tgz#6c7f7398dd4b3142767f9a168af2f317a428d268" + integrity sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w== js-sha256@^0.9.0: version "0.9.0" @@ -7454,9 +7456,9 @@ language-tags@^1.0.9: language-subtag-registry "^0.3.20" launch-editor@^2.6.0: - version "2.6.1" - resolved "https://registry.yarnpkg.com/launch-editor/-/launch-editor-2.6.1.tgz#f259c9ef95cbc9425620bbbd14b468fcdb4ffe3c" - integrity sha512-eB/uXmFVpY4zezmGp5XtU21kwo7GBbKB+EQ+UZeWtGb9yAM5xt/Evk+lYH3eRNAtId+ej4u7TYPFZ07w4s7rRw== + version "2.8.0" + resolved "https://registry.yarnpkg.com/launch-editor/-/launch-editor-2.8.0.tgz#7255d90bdba414448e2138faa770a74f28451305" + integrity sha512-vJranOAJrI/llyWGRQqiDM+adrw+k83fvmmx3+nV47g3+36xM15jE+zyZ6Ffel02+xSvuM0b2GDRosXZkbb6wA== dependencies: picocolors "^1.0.0" shell-quote "^1.8.1" @@ -7657,9 +7659,9 @@ lower-case@^2.0.2: tslib "^2.0.3" lru-cache@^10.2.0: - version "10.2.2" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.2.2.tgz#48206bc114c1252940c41b25b41af5b545aca878" - integrity sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ== + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== lru-cache@^4.0.1: version "4.1.5" @@ -7759,11 +7761,16 @@ micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5: braces "^3.0.3" picomatch "^2.3.1" -mime-db@1.52.0, "mime-db@>= 1.43.0 < 2": +mime-db@1.52.0: version "1.52.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== +"mime-db@>= 1.43.0 < 2": + version "1.53.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.53.0.tgz#3cb63cd820fc29896d9d4e8c32ab4fcd74ccb447" + integrity sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg== + mime-db@~1.33.0: version "1.33.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.33.0.tgz#a3492050a5cb9b63450541e39d9788d2272783db" @@ -7828,9 +7835,9 @@ minimatch@^5.0.1: brace-expansion "^2.0.1" minimatch@^9.0.4: - version "9.0.4" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.4.tgz#8e49c731d1749cbec05050ee5145147b32496a51" - integrity sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw== + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== dependencies: brace-expansion "^2.0.1" @@ -7952,9 +7959,9 @@ node-int64@^0.4.0: integrity sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw== node-releases@^2.0.14: - version "2.0.14" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b" - integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw== + version "2.0.18" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.18.tgz#f010e8d35e2fe8d6b2944f03f70213ecedc4ca3f" + integrity sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g== normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" @@ -8000,9 +8007,9 @@ nth-check@^2.0.1: boolbase "^1.0.0" nwsapi@^2.2.0: - version "2.2.10" - resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.10.tgz#0b77a68e21a0b483db70b11fad055906e867cda8" - integrity sha512-QK0sRs7MKv0tKe1+5uZIQk/C8XGza4DAnztJG8iD+TpJIORARrCxczA738awHrZoHeTjSSoHqao2teO0dC/gFQ== + version "2.2.12" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.12.tgz#fb6af5c0ec35b27b4581eb3bbad34ec9e5c696f8" + integrity sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w== object-assign@^4.0.1, object-assign@^4.1.1: version "4.1.1" @@ -8015,9 +8022,9 @@ object-hash@^3.0.0: integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw== object-inspect@^1.13.1, object-inspect@^1.7.0: - version "1.13.1" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2" - integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ== + version "1.13.2" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.2.tgz#dea0088467fb991e67af4058147a24824a3043ff" + integrity sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g== object-is@^1.0.2, object-is@^1.1.5: version "1.1.6" @@ -8042,7 +8049,7 @@ object.assign@^4.1.0, object.assign@^4.1.4, object.assign@^4.1.5: has-symbols "^1.0.3" object-keys "^1.1.1" -object.entries@^1.1.1, object.entries@^1.1.7, object.entries@^1.1.8: +object.entries@^1.1.1, object.entries@^1.1.8: version "1.1.8" resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.8.tgz#bffe6f282e01f4d17807204a24f8edd823599c41" integrity sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ== @@ -8083,15 +8090,6 @@ object.groupby@^1.0.1: define-properties "^1.2.1" es-abstract "^1.23.2" -object.hasown@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/object.hasown/-/object.hasown-1.1.4.tgz#e270ae377e4c120cdcb7656ce66884a6218283dc" - integrity sha512-FZ9LZt9/RHzGySlBARE3VF+gE26TxR38SdmqOqliuTnl9wrKulaQs+4dee1V+Io8VfxqzAfHu6YuRgUy8OHoTg== - dependencies: - define-properties "^1.2.1" - es-abstract "^1.23.2" - es-object-atoms "^1.0.0" - object.values@^1.1.0, object.values@^1.1.1, object.values@^1.1.6, object.values@^1.1.7, object.values@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.2.0.tgz#65405a9d92cee68ac2d303002e0b8470a4d9ab1b" @@ -8230,6 +8228,11 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +package-json-from-dist@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz#e501cd3094b278495eb4258d4c9f6d5ac3019f00" + integrity sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw== + param-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5" @@ -8700,11 +8703,11 @@ postcss-modules-values@^4.0.0: icss-utils "^5.0.0" postcss-nested@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-6.0.1.tgz#f83dc9846ca16d2f4fa864f16e9d9f7d0961662c" - integrity sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ== + version "6.2.0" + resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-6.2.0.tgz#4c2d22ab5f20b9cb61e2c5c5915950784d068131" + integrity sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ== dependencies: - postcss-selector-parser "^6.0.11" + postcss-selector-parser "^6.1.1" postcss-nesting@^10.2.0: version "10.2.0" @@ -8907,10 +8910,10 @@ postcss-selector-not@^6.0.1: dependencies: postcss-selector-parser "^6.0.10" -postcss-selector-parser@^6.0.10, postcss-selector-parser@^6.0.11, postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.5, postcss-selector-parser@^6.0.9: - version "6.1.0" - resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.1.0.tgz#49694cb4e7c649299fea510a29fa6577104bcf53" - integrity sha512-UMz42UD0UY0EApS0ZL9o1XnLhSTtvvvLe5Dc2H2O56fvRZi+KulDyf5ctDhhtYJBGKStV2FL1fy6253cmLgqVQ== +postcss-selector-parser@^6.0.10, postcss-selector-parser@^6.0.11, postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.5, postcss-selector-parser@^6.0.9, postcss-selector-parser@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.1.1.tgz#5be94b277b8955904476a2400260002ce6c56e38" + integrity sha512-b4dlw/9V8A71rLIDsSwVmak9z2DuBUB7CA1/wSdelNEzqsjoSPeADTWNO09lpH49Diy3/JIZ2bSPB1dI3LJCHg== dependencies: cssesc "^3.0.0" util-deprecate "^1.0.2" @@ -8944,12 +8947,12 @@ postcss@^7.0.35: source-map "^0.6.1" postcss@^8.3.5, postcss@^8.4.23, postcss@^8.4.33, postcss@^8.4.4: - version "8.4.38" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e" - integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A== + version "8.4.39" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.39.tgz#aa3c94998b61d3a9c259efa51db4b392e1bde0e3" + integrity sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw== dependencies: nanoid "^3.3.7" - picocolors "^1.0.0" + picocolors "^1.0.1" source-map-js "^1.2.0" prelude-ls@^1.2.1: @@ -9283,19 +9286,19 @@ react-refresh@^0.11.0: integrity sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A== react-router-dom@^6.22.3: - version "6.23.1" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.23.1.tgz#30cbf266669693e9492aa4fc0dde2541ab02322f" - integrity sha512-utP+K+aSTtEdbWpC+4gxhdlPFwuEfDKq8ZrPFU65bbRJY+l706qjR7yaidBpo3MSeA/fzwbXWbKBI6ftOnP3OQ== + version "6.25.1" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.25.1.tgz#b89f8d63fc8383ea4e89c44bf31c5843e1f7afa0" + integrity sha512-0tUDpbFvk35iv+N89dWNrJp+afLgd+y4VtorJZuOCXK0kkCWjEvb3vTJM++SYvMEpbVwXKf3FjeVveVEb6JpDQ== dependencies: - "@remix-run/router" "1.16.1" - react-router "6.23.1" + "@remix-run/router" "1.18.0" + react-router "6.25.1" -react-router@6.23.1: - version "6.23.1" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.23.1.tgz#d08cbdbd9d6aedc13eea6e94bc6d9b29cb1c4be9" - integrity sha512-fzcOaRF69uvqbbM7OhvQyBTFDVrrGlsFdS3AL+1KfIBtGETibHzi3FkoTRyiDJnWNc2VxrfvR+657ROHjaNjqQ== +react-router@6.25.1: + version "6.25.1" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.25.1.tgz#70b4f1af79954cfcfd23f6ddf5c883e8c904203e" + integrity sha512-u8ELFr5Z6g02nUtpPAggP73Jigj1mRePSwhS/2nkTrlPU5yEkH1vYzWNyvSnSzeeE2DNqWdH+P8OhIh9wuXhTw== dependencies: - "@remix-run/router" "1.16.1" + "@remix-run/router" "1.18.0" react-scripts@^5.0.1: version "5.0.1" @@ -9616,9 +9619,9 @@ reusify@^1.0.4: integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== rfdc@^1.3.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.1.tgz#2b6d4df52dffe8bb346992a10ea9451f24373a8f" - integrity sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg== + version "1.4.1" + resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.4.1.tgz#778f76c4fb731d93414e8f925fbecf64cce7f6ca" + integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== rimraf@^3.0.0, rimraf@^3.0.2: version "3.0.2" @@ -9793,9 +9796,9 @@ semver@^6.0.0, semver@^6.3.0, semver@^6.3.1: integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== semver@^7.2.1, semver@^7.3.2, semver@^7.3.5, semver@^7.3.7, semver@^7.5.3, semver@^7.5.4: - version "7.6.2" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13" - integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w== + version "7.6.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" + integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== send@0.18.0: version "0.18.0" @@ -10206,6 +10209,14 @@ string-width@^5.0.1, string-width@^5.1.2: emoji-regex "^9.2.2" strip-ansi "^7.0.1" +string.prototype.includes@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/string.prototype.includes/-/string.prototype.includes-2.0.0.tgz#8986d57aee66d5460c144620a6d873778ad7289f" + integrity sha512-E34CkBgyeqNDcrbU76cDjL5JLcVrtSdYq0MEh/B10r17pRP4ciHLwTgnuLV8Ay6cgEMLkcBkFCKyFZ43YldYzg== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + string.prototype.matchall@^4.0.11, string.prototype.matchall@^4.0.6: version "4.0.11" resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.11.tgz#1092a72c59268d2abaad76582dccc687c0297e0a" @@ -10224,6 +10235,14 @@ string.prototype.matchall@^4.0.11, string.prototype.matchall@^4.0.6: set-function-name "^2.0.2" side-channel "^1.0.6" +string.prototype.repeat@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz#e90872ee0308b29435aa26275f6e1b762daee01a" + integrity sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + string.prototype.trim@^1.2.1, string.prototype.trim@^1.2.9: version "1.2.9" resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz#b6fa326d72d2c78b6df02f7759c73f8f6274faa4" @@ -10457,9 +10476,9 @@ table@^6.0.9: strip-ansi "^6.0.1" tailwindcss@^3.0.2: - version "3.4.4" - resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.4.tgz#351d932273e6abfa75ce7d226b5bf3a6cb257c05" - integrity sha512-ZoyXOdJjISB7/BcLTR6SEsLgKtDStYyYZVLsUtWChO4Ps20CBad7lfJKVDiejocV4ME1hLmyY0WJE3hSDcmQ2A== + version "3.4.6" + resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.6.tgz#41faae16607e0916da1eaa4a3b44053457ba70dd" + integrity sha512-1uRHzPB+Vzu57ocybfZ4jh5Q3SdlH7XW23J5sQoM9LhE9eIOlzxer/3XPSsycvih3rboRsvt0QCmzSrqyOYUIA== dependencies: "@alloc/quick-lru" "^5.2.0" arg "^5.0.2" @@ -10536,9 +10555,9 @@ terser-webpack-plugin@^5.2.5, terser-webpack-plugin@^5.3.10: terser "^5.26.0" terser@^5.0.0, terser@^5.10.0, terser@^5.26.0: - version "5.31.1" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.31.1.tgz#735de3c987dd671e95190e6b98cfe2f07f3cf0d4" - integrity sha512-37upzU1+viGvuFtBo9NPufCb9dwM0+l9hMxYyWfBA+fbwrPqNJAhbZ6W47bBFnZHKHTUBnMvi87434qq+qnxOg== + version "5.31.3" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.31.3.tgz#b24b7beb46062f4653f049eea4f0cd165d0f0c38" + integrity sha512-pAfYn3NIZLyZpa83ZKigvj6Rn9c/vd5KfYGX7cN1mnzqgDcxWvrU5ZtAfIKhEXz9nRecw4z3LXkjaq96/qZqAA== dependencies: "@jridgewell/source-map" "^0.3.3" acorn "^8.8.2" @@ -10594,9 +10613,9 @@ thunky@^1.0.2: integrity sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA== tlds@^1.199.0: - version "1.252.0" - resolved "https://registry.yarnpkg.com/tlds/-/tlds-1.252.0.tgz#71d9617f4ef4cc7347843bee72428e71b8b0f419" - integrity sha512-GA16+8HXvqtfEnw/DTcwB0UU354QE1n3+wh08oFjr6Znl7ZLAeUgYzCcK+/CCrOyE0vnHR8/pu3XXG3vDijXpQ== + version "1.254.0" + resolved "https://registry.yarnpkg.com/tlds/-/tlds-1.254.0.tgz#7131955376f7c195d5edc0a94b4a203bc36668d0" + integrity sha512-YY4ei7K7gPGifqNSrfMaPdqTqiHcwYKUJ7zhLqQOK2ildlGgti5TSwJiXXN1YqG17I2GYZh5cZqv2r5fwBUM+w== tmp@~0.2.1: version "0.2.3" @@ -10881,10 +10900,10 @@ upath@^1.2.0: resolved "https://registry.yarnpkg.com/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894" integrity sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg== -update-browserslist-db@^1.0.16: - version "1.0.16" - resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz#f6d489ed90fb2f07d67784eb3f53d7891f736356" - integrity sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ== +update-browserslist-db@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz#7ca61c0d8650766090728046e416a8cde682859e" + integrity sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ== dependencies: escalade "^3.1.2" picocolors "^1.0.1" @@ -10897,7 +10916,7 @@ update-check@1.5.2: registry-auth-token "3.3.2" registry-url "3.1.0" -uri-js@^4.2.2, uri-js@^4.4.1: +uri-js@^4.2.2: version "4.4.1" resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== @@ -11311,9 +11330,9 @@ webpack-sources@^3.2.3: integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== webpack@^5.64.4: - version "5.91.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.91.0.tgz#ffa92c1c618d18c878f06892bbdc3373c71a01d9" - integrity sha512-rzVwlLeBWHJbmgTC/8TvAcu5vpJNII+MelQpylD4jNERPwpBJOE2lEcko1zJX3QJeLjTTAnQxn/OJ8bjDzVQaw== + version "5.93.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.93.0.tgz#2e89ec7035579bdfba9760d26c63ac5c3462a5e5" + integrity sha512-Y0m5oEY1LRuwly578VqluorkXbvXKh7U3rLoQCEO04M97ScRr44afGVkI0FQFsXzysk5OgFAxjZAb9rsGQVihA== dependencies: "@types/eslint-scope" "^3.7.3" "@types/estree" "^1.0.5" @@ -11321,10 +11340,10 @@ webpack@^5.64.4: "@webassemblyjs/wasm-edit" "^1.12.1" "@webassemblyjs/wasm-parser" "^1.12.1" acorn "^8.7.1" - acorn-import-assertions "^1.9.0" + acorn-import-attributes "^1.9.5" browserslist "^4.21.10" chrome-trace-event "^1.0.2" - enhanced-resolve "^5.16.0" + enhanced-resolve "^5.17.0" es-module-lexer "^1.2.1" eslint-scope "5.1.1" events "^3.2.0" @@ -11696,9 +11715,9 @@ ws@^7.4.6: integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== ws@^8.13.0: - version "8.17.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" - integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== + version "8.18.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" + integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== xml-name-validator@^3.0.0: version "3.0.0" 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." From 908338575f62ed5990f4755d3fc7923fb11c33f6 Mon Sep 17 00:00:00 2001 From: mshriver Date: Mon, 17 Jun 2024 11:29:39 -0400 Subject: [PATCH 3/4] Model and controller updates for portals Portal does not have relationship to runs, results, etc. only dashboards. DB upgrade_6, dashboard and widget_config alterations New controller for portal and portal admin Updates to widget_config controller and dashboard controller Update openapi spec and add unit test Restructure test_widget_config_controller Increase coverage with subtests Define common headers and http response assertion failure message for tests --- .../controllers/admin/portal_controller.py | 151 ++++++++ .../controllers/admin/project_controller.py | 63 ++-- .../controllers/dashboard_controller.py | 53 ++- .../controllers/portal_controller.py | 129 +++++++ .../controllers/widget_config_controller.py | 60 +++- backend/ibutsu_server/db/models.py | 33 +- backend/ibutsu_server/db/upgrades.py | 77 +++- backend/ibutsu_server/openapi/openapi.yaml | 331 ++++++++++++++++- backend/ibutsu_server/test/__init__.py | 73 +++- .../test/test_artifact_controller.py | 45 +-- .../test/test_group_controller.py | 29 +- .../test/test_health_controller.py | 18 +- .../test/test_login_controller.py | 34 +- .../test/test_portal_controller.py | 155 ++++++++ .../test/test_project_controller.py | 49 +-- .../test/test_report_controller.py | 32 +- .../test/test_result_controller.py | 40 +-- .../ibutsu_server/test/test_run_controller.py | 49 +-- .../test/test_widget_config_controller.py | 332 +++++++++++++----- .../test/test_widget_controller.py | 9 +- backend/ibutsu_server/util/portals.py | 17 + 21 files changed, 1438 insertions(+), 341 deletions(-) create mode 100644 backend/ibutsu_server/controllers/admin/portal_controller.py create mode 100644 backend/ibutsu_server/controllers/portal_controller.py create mode 100644 backend/ibutsu_server/test/test_portal_controller.py create mode 100644 backend/ibutsu_server/util/portals.py diff --git a/backend/ibutsu_server/controllers/admin/portal_controller.py b/backend/ibutsu_server/controllers/admin/portal_controller.py new file mode 100644 index 00000000..3dbe29b2 --- /dev/null +++ b/backend/ibutsu_server/controllers/admin/portal_controller.py @@ -0,0 +1,151 @@ +from http import HTTPStatus + +import connexion +from flask import abort + +from ibutsu_server.constants import RESPONSE_JSON_REQ +from ibutsu_server.db.base import session +from ibutsu_server.db.models import Portal, User +from ibutsu_server.filters import convert_filter +from ibutsu_server.util.admin import check_user_is_admin +from ibutsu_server.util.query import get_offset +from ibutsu_server.util.uuid import convert_objectid_to_uuid, is_uuid, validate_uuid + + +def admin_add_portal(portal=None, token_info=None, user=None) -> tuple[dict, int]: + """Create a portal + + :param body: Portal + :type body: dict | bytes + + :rtype: Portal + """ + check_user_is_admin(user) + if not connexion.request.is_json: + return RESPONSE_JSON_REQ + portal = Portal.from_dict(**connexion.request.get_json()) + # check if portal already exists + if portal.id and Portal.query.get(portal.id): + return f"Portal id {portal.id} already exist", HTTPStatus.BAD_REQUEST + if user := User.query.get(user): + portal.owner = user + session.add(portal) + session.commit() + return portal.to_dict(), HTTPStatus.CREATED + + +@validate_uuid +def admin_get_portal(id_, token_info=None, user=None) -> dict: + """Get a single portal by ID + + :param id: ID of test portal + :type id: str + + :rtype: Portal + """ + check_user_is_admin(user) + + # get by ID or check if the portal name matches the passed ID + if portal := Portal.query.get(id_) or Portal.query.filter(Portal.name == id_).first(): + return portal.to_dict(with_owner=True) + else: + abort(HTTPStatus.NOT_FOUND) + + +def admin_get_portal_list( + filter_=None, + owner_id=None, + group_id=None, + page=1, + page_size=25, + token_info=None, + user=None, +) -> dict[list[dict], dict]: + """Get a list of portals + + :param owner_id: Filter portals by owner ID + :type owner_id: str + :param group_id: Filter portals by group ID + :type group_id: str + :param limit: Limit the portals + :type limit: int + :param offset: Offset the portals + :type offset: int + + :rtype: List[Portal] + """ + check_user_is_admin(user) + query = Portal.query + + if filter_: + for filter_string in filter_: + filter_clause = convert_filter(filter_string, Portal) + if filter_clause is not None: + query = query.filter(filter_clause) + if owner_id: + query = query.filter(Portal.owner_id == owner_id) + if group_id: + query = query.filter(Portal.group_id == group_id) + + offset = get_offset(page, page_size) + total_items = query.count() + total_pages = (total_items // page_size) + (1 if total_items % page_size > 0 else 0) + if offset > 9223372036854775807: # max value of bigint + return "The page number is too big.", HTTPStatus.BAD_REQUEST + portals = query.offset(offset).limit(page_size).all() + return { + "portals": [portal.to_dict(with_owner=True) for portal in portals], + "pagination": { + "page": page, + "pageSize": page_size, + "totalItems": total_items, + "totalPages": total_pages, + }, + } + + +@validate_uuid +def admin_update_portal(id_, portal=None, body=None, token_info=None, user=None): + """Update a portal + + :param id: ID of portal + :type id: str + :param body: Portal + :type body: dict | bytes + + :rtype: Portal + """ + check_user_is_admin(user) + if not connexion.request.is_json: + return RESPONSE_JSON_REQ + if not is_uuid(id_): + id_ = convert_objectid_to_uuid(id_) + + if portal := Portal.query.get(id_): + # Grab the fields from the request + portal_dict = connexion.request.get_json() + + # If the "owner" field is set, ignore it + portal_dict.pop("owner", None) + + # update the portal info + portal.update(portal_dict) + session.add(portal) + session.commit() + return portal.to_dict() + else: + abort(HTTPStatus.NOT_FOUND) + + +@validate_uuid +def admin_delete_portal(id_, token_info=None, user=None): + """Delete a single portal""" + check_user_is_admin(user) + if not is_uuid(id_): + return f"Portal ID {id_} is not in UUID format", HTTPStatus.BAD_REQUEST + if portal := Portal.query.get(id_): + session.delete(portal) + session.commit() + return HTTPStatus.OK.phrase, HTTPStatus.OK + else: + abort(HTTPStatus.NOT_FOUND) diff --git a/backend/ibutsu_server/controllers/admin/project_controller.py b/backend/ibutsu_server/controllers/admin/project_controller.py index eb75eb55..ce1d5b8f 100644 --- a/backend/ibutsu_server/controllers/admin/project_controller.py +++ b/backend/ibutsu_server/controllers/admin/project_controller.py @@ -51,12 +51,11 @@ def admin_get_project(id_, token_info=None, user=None): :rtype: Project """ check_user_is_admin(user) - project = Project.query.get(id_) - if not project: - project = Project.query.filter(Project.name == id_).first() - if not project: + + if project := Project.query.get(id_) or Project.query.filter(Project.name == id_).first(): + return project.to_dict(with_owner=True) + else: abort(HTTPStatus.NOT_FOUND) - return project.to_dict(with_owner=True) def admin_get_project_list( @@ -127,35 +126,34 @@ def admin_update_project(id_, project=None, body=None, token_info=None, user=Non return RESPONSE_JSON_REQ if not is_uuid(id_): id_ = convert_objectid_to_uuid(id_) - project = Project.query.get(id_) - if not project: + if project := Project.query.get(id_): + # Grab the fields from the request + project_dict = connexion.request.get_json() + + # If the "owner" field is set, ignore it + project_dict.pop("owner", None) + + # handle updating users separately + for username in project_dict.pop("users", []): + user_to_add = User.query.filter_by(email=username).first() + if user_to_add and user_to_add not in project.users: + project.users.append(user_to_add) + + # Make sure the project owner is in the list of users + if project_dict.get("owner_id"): + owner = User.query.get(project_dict["owner_id"]) + if owner and owner not in project.users: + project.users.append(owner) + + # update the rest of the project info + project.update(project_dict) + session.add(project) + session.commit() + return project.to_dict() + else: abort(HTTPStatus.NOT_FOUND) - # Grab the fields from the request - project_dict = connexion.request.get_json() - - # If the "owner" field is set, ignore it - project_dict.pop("owner", None) - - # handle updating users separately - for username in project_dict.pop("users", []): - user_to_add = User.query.filter_by(email=username).first() - if user_to_add and user_to_add not in project.users: - project.users.append(user_to_add) - - # Make sure the project owner is in the list of users - if project_dict.get("owner_id"): - owner = User.query.get(project_dict["owner_id"]) - if owner and owner not in project.users: - project.users.append(owner) - - # update the rest of the project info - project.update(project_dict) - session.add(project) - session.commit() - return project.to_dict() - @validate_uuid def admin_delete_project(id_, token_info=None, user=None): @@ -170,6 +168,3 @@ def admin_delete_project(id_, token_info=None, user=None): return HTTPStatus.OK.phrase, HTTPStatus.OK else: abort(HTTPStatus.NOT_FOUND) - session.delete(project) - session.commit() - return HTTPStatus.OK.phrase, HTTPStatus.OK diff --git a/backend/ibutsu_server/controllers/dashboard_controller.py b/backend/ibutsu_server/controllers/dashboard_controller.py index 24037752..9ed0f4c7 100644 --- a/backend/ibutsu_server/controllers/dashboard_controller.py +++ b/backend/ibutsu_server/controllers/dashboard_controller.py @@ -11,7 +11,7 @@ from ibutsu_server.util.uuid import validate_uuid -def add_dashboard(dashboard=None, token_info=None, user=None): +def add_dashboard(dashboard=None, token_info=None, user=None) -> tuple[dict, int]: """Create a dashboard :param body: Dashboard @@ -22,17 +22,30 @@ def add_dashboard(dashboard=None, token_info=None, user=None): if not connexion.request.is_json: return RESPONSE_JSON_REQ dashboard = Dashboard.from_dict(**connexion.request.get_json()) + + if dashboard.portal_id and dashboard.project_id: + return "Dashboard can only have one of project_id or portal_id", HTTPStatus.BAD_REQUEST + + if not (dashboard.portal_id or dashboard.project_id): + return "Dashboard needs either project_id or portal_id", HTTPStatus.BAD_REQUEST + if dashboard.project_id and not project_has_user(dashboard.project_id, user): return HTTPStatus.FORBIDDEN.phrase, HTTPStatus.FORBIDDEN + + # TODO utility function to resolve all users with at least one project set + # compare to projects assigned to the portal? or portals are open to all projects + # otherwise, limit to admin users or new portal_admin permission + if dashboard.user_id and not User.query.get(dashboard.user_id): return f"User with ID {dashboard.user_id} doesn't exist", HTTPStatus.BAD_REQUEST + session.add(dashboard) session.commit() return dashboard.to_dict(), HTTPStatus.CREATED @validate_uuid -def get_dashboard(id_, token_info=None, user=None): +def get_dashboard(id_, token_info=None, user=None) -> dict: """Get a single dashboard by ID :param id: ID of test dashboard @@ -45,16 +58,19 @@ def get_dashboard(id_, token_info=None, user=None): return "Dashboard not found", HTTPStatus.NOT_FOUND if dashboard and dashboard.project and not project_has_user(dashboard.project, user): return HTTPStatus.FORBIDDEN.phrase, HTTPStatus.FORBIDDEN + # TODO test against dashboard with only portal set return dashboard.to_dict() def get_dashboard_list( - filter_=None, project_id=None, page=1, page_size=25, token_info=None, user=None -): + filter_=None, project_id=None, portal_id=None, page=1, page_size=25, token_info=None, user=None +) -> dict[list[dict], dict]: """Get a list of dashboards :param project_id: Filter dashboards by project ID :type project_id: str + :param portal_id: Filter dashboards by portal ID + :type portal_id: str :param user_id: Filter dashboards by user ID :type user_id: str :param limit: Limit the dashboards @@ -66,6 +82,11 @@ def get_dashboard_list( """ query = Dashboard.query project = None + portal = None + if portal_id is not None and project_id is not None: + return "Dashboard list can only have one of project_id or portal_id", HTTPStatus.BAD_REQUEST + + # Project filter injection if "project_id" in connexion.request.args: project = Project.query.get(connexion.request.args["project_id"]) if project: @@ -73,6 +94,13 @@ def get_dashboard_list( return HTTPStatus.FORBIDDEN.phrase, HTTPStatus.FORBIDDEN query = query.filter(Dashboard.project_id == project_id) + # Portal filter injection + if "portal_id" in connexion.request.args: + portal = Project.query.get(connexion.request.args["portal_id"]) + if portal: + query = query.filter(Dashboard.portal_id == portal_id) + + # Other filters follow if filter_: for filter_string in filter_: filter_clause = convert_filter(filter_string, Dashboard) @@ -96,10 +124,10 @@ def get_dashboard_list( @validate_uuid -def update_dashboard(id_, dashboard=None, token_info=None, user=None): +def update_dashboard(id_, dashboard=None, token_info=None, user=None) -> dict: """Update a dashboard - :param id: ID of test dashboard + :param id: ID of dashboard :type id: str :param body: Dashboard :type body: dict | bytes @@ -113,11 +141,17 @@ def update_dashboard(id_, dashboard=None, token_info=None, user=None): dashboard_dict["metadata"]["project"], user ): return HTTPStatus.FORBIDDEN.phrase, HTTPStatus.FORBIDDEN + + # TODO user/admin check for portal ref + dashboard = Dashboard.query.get(id_) if not dashboard: return "Dashboard not found", HTTPStatus.NOT_FOUND - if project_has_user(dashboard.project, user): + if dashboard.project_id is not None and project_has_user(dashboard.project, user): return HTTPStatus.FORBIDDEN.phrase, HTTPStatus.FORBIDDEN + + # TODO user/admin check for portal ref + dashboard.update(connexion.request.get_json()) session.add(dashboard) session.commit() @@ -125,7 +159,7 @@ def update_dashboard(id_, dashboard=None, token_info=None, user=None): @validate_uuid -def delete_dashboard(id_, token_info=None, user=None): +def delete_dashboard(id_, token_info=None, user=None) -> tuple[str, int]: """Deletes a dashboard :param id: ID of the dashboard to delete @@ -136,8 +170,9 @@ def delete_dashboard(id_, token_info=None, user=None): dashboard = Dashboard.query.get(id_) if not dashboard: return HTTPStatus.NOT_FOUND.phrase, HTTPStatus.NOT_FOUND - if not project_has_user(dashboard.project, user): + if dashboard.project_id is not None and not project_has_user(dashboard.project, user): return HTTPStatus.FORBIDDEN.phrase, HTTPStatus.FORBIDDEN + # TODO user/admin check for portal ref widget_configs = WidgetConfig.query.filter(WidgetConfig.dashboard_id == dashboard.id).all() for widget_config in widget_configs: session.delete(widget_config) diff --git a/backend/ibutsu_server/controllers/portal_controller.py b/backend/ibutsu_server/controllers/portal_controller.py new file mode 100644 index 00000000..15aa68d4 --- /dev/null +++ b/backend/ibutsu_server/controllers/portal_controller.py @@ -0,0 +1,129 @@ +from http import HTTPStatus + +import connexion + +from ibutsu_server.constants import RESPONSE_JSON_REQ +from ibutsu_server.db.base import session +from ibutsu_server.db.models import Portal, User +from ibutsu_server.filters import convert_filter +from ibutsu_server.util.query import get_offset +from ibutsu_server.util.uuid import convert_objectid_to_uuid, is_uuid, validate_uuid + + +def add_portal(portal=None, token_info=None, user=None) -> dict: + """Create a portal + + :param body: Portal + :type body: dict | bytes + + :rtype: Portal + """ + if not connexion.request.is_json: + return RESPONSE_JSON_REQ + portal = Portal.from_dict(**connexion.request.get_json()) + # check if portal already exists + if portal.id and Portal.query.get(portal.id): + return f"Portal id {portal.id} already exists.", HTTPStatus.CONFLICT + user = User.query.get(user) + if user: + portal.owner = user + session.add(portal) + session.commit() + return portal.to_dict(), HTTPStatus.CREATED + + +@validate_uuid +def get_portal(id_, token_info=None, user=None) -> dict: + """Get a single portal by ID + + :param id: ID of test portal + :type id: str + + :rtype: Portal + """ + if not is_uuid(id_): + id_ = convert_objectid_to_uuid(id_) + portal = Portal.query.filter(Portal.name == id_).first() + if not portal: + portal = Portal.query.get(id_) + # any user can get portals + if not portal: + return "Portal not found", HTTPStatus.NOT_FOUND + return portal.to_dict() + + +def get_portal_list( + filter_=None, + owner_id=None, + page=1, + page_size=25, + token_info=None, + user=None, +) -> dict[list[dict], dict]: + """Get a list of portals + + :param owner_id: Filter portals by owner ID + :type owner_id: str + :param limit: Limit the portals + :type limit: int + :param offset: Offset the portals + :type offset: int + + :rtype: List[Portal] + """ + # TODO evaluate and test this filter need + query = Portal.query + if owner_id: + query = query.filter(Portal.owner_id == owner_id) + + if filter_: + for filter_string in filter_: + filter_clause = convert_filter(filter_string, Portal) + if filter_clause is not None: + query = query.filter(filter_clause) + + offset = get_offset(page, page_size) + total_items = query.count() + total_pages = (total_items // page_size) + (1 if total_items % page_size > 0 else 0) + portals = query.offset(offset).limit(page_size).all() + return { + "portals": [portal.to_dict() for portal in portals], + "pagination": { + "page": page, + "pageSize": page_size, + "totalItems": total_items, + "totalPages": total_pages, + }, + } + + +@validate_uuid +def update_portal(id_, portal=None, token_info=None, user=None, **kwargs) -> dict: + """Update a portal + + :param id: ID of portal + :type id: str + :param body: Portal + :type body: dict | bytes + + :rtype: Portal + """ + if not connexion.request.is_json: + return RESPONSE_JSON_REQ + if not is_uuid(id_): + id_ = convert_objectid_to_uuid(id_) + portal = Portal.query.get(id_) + + if not portal: + return "Portal not found", HTTPStatus.NOT_FOUND + + user = User.query.get(user) + if not user.is_superadmin and (not portal.owner or portal.owner.id != user.id): + return HTTPStatus.FORBIDDEN.phrase, HTTPStatus.FORBIDDEN + + # update the portal info + updates = connexion.request.get_json() + portal.update(updates) + session.add(portal) + session.commit() + return portal.to_dict() diff --git a/backend/ibutsu_server/controllers/widget_config_controller.py b/backend/ibutsu_server/controllers/widget_config_controller.py index 48e19374..2609ea59 100644 --- a/backend/ibutsu_server/controllers/widget_config_controller.py +++ b/backend/ibutsu_server/controllers/widget_config_controller.py @@ -7,6 +7,7 @@ from ibutsu_server.db.base import session from ibutsu_server.db.models import WidgetConfig from ibutsu_server.filters import convert_filter +from ibutsu_server.util.portals import get_portal from ibutsu_server.util.projects import get_project, project_has_user from ibutsu_server.util.query import get_offset from ibutsu_server.util.uuid import validate_uuid @@ -25,23 +26,46 @@ def add_widget_config(widget_config=None, token_info=None, user=None): if not connexion.request.is_json: return RESPONSE_JSON_REQ data = connexion.request.json + + # bad request checks if data["widget"] not in WIDGET_TYPES.keys(): return "Bad request, widget type does not exist", HTTPStatus.BAD_REQUEST + # TODO: openapi plugins to support exclusive options at the schema level + if ( + not any(data.get(key) for key in ["portal_id", "portal", "project_id", "project"]) + or (data.get("project_id") or data.get("project")) + and (data.get("portal_id") or data.get("portal")) + ): + return ( + "Bad request, widget config requires one of project or portal", + HTTPStatus.BAD_REQUEST, + ) # add default weight of 10 - if not data.get("weight"): - data["weight"] = 10 - # Look up the project id + data["weight"] = data.get("weight", 10) + + # project relationship + # TODO BAD_REQUEST when the project doesn't resolve if data.get("project"): project = get_project(data.pop("project")) if not project_has_user(project, user): return HTTPStatus.FORBIDDEN.phrase, HTTPStatus.FORBIDDEN data["project_id"] = project.id + + # portal relationship + # TODO BAD_REQUEST when the portal doesn't resolve + if data.get("portal"): + portal = get_portal(data.pop("portal")) + # TODO portal user/admin check + data["portal_id"] = portal.id + # default to make views navigable if data.get("navigable") and isinstance(data["navigable"], str): data["navigable"] = data["navigable"][0] in ALLOWED_TRUE_BOOLEANS if data.get("type") == "view" and data.get("navigable") is None: data["navigable"] = True + + # commit the classmethod constructed model widget_config = WidgetConfig.from_dict(**data) session.add(widget_config) session.commit() @@ -83,6 +107,10 @@ def get_widget_config_list(filter_=None, page=1, page_size=25): WidgetConfig.project_id.is_(None), convert_filter(filter_string, WidgetConfig), ) + elif "portal" in filter_string: + filter_clause = or_( + WidgetConfig.portal_id.is_(None), convert_filter(filter_string, WidgetConfig) + ) else: filter_clause = convert_filter(filter_string, WidgetConfig) if filter_clause is not None: @@ -116,20 +144,39 @@ def update_widget_config(id_, body=None, widget_config=None, token_info=None, us if not connexion.request.is_json: return RESPONSE_JSON_REQ data = connexion.request.get_json() + + # Bad request checks if data.get("widget") and data["widget"] not in WIDGET_TYPES.keys(): return "Bad request, widget type does not exist", HTTPStatus.BAD_REQUEST - # Look up the project id + # check portal and project fields if any of them are set + # not required on an update, existing entity should already have it. + if any(data.get(key) for key in ["portal_id", "portal", "project_id", "project"]): + if (data.get("project_id") or data.get("project")) and ( + data.get("portal_id") or data.get("portal") + ): + return ( + "Bad request, widget config requires one of project or portal", + HTTPStatus.BAD_REQUEST, + ) + + # project relationship if data.get("project"): project = get_project(data.pop("project")) if not project_has_user(project, user): return HTTPStatus.FORBIDDEN.phrase, HTTPStatus.FORBIDDEN data["project_id"] = project.id + + # portal relationship + if data.get("portal"): + portal = get_portal(data.pop("portal")) + # TODO portal user/admin check + data["portal_id"] = portal.id + widget_config = WidgetConfig.query.get(id_) if not widget_config: return "Widget config not found", HTTPStatus.NOT_FOUND # add default weight of 10 - if not widget_config.weight: - widget_config.weight = 10 + widget_config.weight = getattr(widget_config, "weight", None) or 10 # default to make views navigable if data.get("navigable") and isinstance(data["navigable"], str): data["navigable"] = data["navigable"][0] in ALLOWED_TRUE_BOOLEANS @@ -156,6 +203,7 @@ def delete_widget_config(id_, token_info=None, user=None): else: if widget_config.project and not project_has_user(widget_config.project, user): return HTTPStatus.FORBIDDEN.phrase, HTTPStatus.FORBIDDEN + # TODO portal user/admin check session.delete(widget_config) session.commit() return HTTPStatus.OK.phrase, HTTPStatus.OK diff --git a/backend/ibutsu_server/db/models.py b/backend/ibutsu_server/db/models.py index 1b5e400c..3bf075d3 100644 --- a/backend/ibutsu_server/db/models.py +++ b/backend/ibutsu_server/db/models.py @@ -94,10 +94,12 @@ class Dashboard(Model, ModelMixin): title = Column(Text, index=True) description = Column(Text, default="") filters = Column(Text, default="") - project_id = Column(PortableUUID(), ForeignKey("projects.id"), index=True) + portal_id = Column(PortableUUID(), ForeignKey("portals.id"), index=True, nullable=True) + project_id = Column(PortableUUID(), ForeignKey("projects.id"), index=True, nullable=True) user_id = Column(PortableUUID(), ForeignKey("users.id"), index=True) widgets = relationship("WidgetConfig") project = relationship("Project", back_populates="dashboards", foreign_keys=[project_id]) + portal = relationship("Portal", back_populates="dashboards", foreign_keys=[portal_id]) class Group(Model, ModelMixin): @@ -147,6 +149,30 @@ def to_dict(self, with_owner=False): return project_dict +class Portal(Model, ModelMixin): + # TODO: Consider common mixin for overlap between project and portal + __tablename__ = "portals" + name = Column(Text, index=True) + title = Column(Text, index=True) + owner_id = Column(PortableUUID(), ForeignKey("users.id"), index=True) + # group_id = Column(PortableUUID(), ForeignKey("groups.id"), index=True) + default_dashboard_id = Column(PortableUUID(), ForeignKey("dashboards.id")) + default_dashboard = relationship("Dashboard", foreign_keys=[default_dashboard_id]) + dashboards = relationship( + "Dashboard", back_populates="portal", foreign_keys=[Dashboard.portal_id] + ) + widget_configs = relationship("WidgetConfig", back_populates="portal") + + def to_dict(self, with_owner=False): + """An overridden method to include the owner""" + portal_dict = super().to_dict() + if with_owner and self.owner: + portal_dict["owner"] = self.owner.to_dict() + if self.default_dashboard: + portal_dict["defaultDashboard"] = self.default_dashboard.to_dict() + return portal_dict + + class Report(Model, ModelMixin): __tablename__ = "reports" created = Column(DateTime, default=datetime.utcnow, index=True) @@ -208,7 +234,8 @@ class WidgetConfig(Model, ModelMixin): __tablename__ = "widget_configs" navigable = Column(Boolean, index=True) params = Column(mutable_json_type(dbtype=PortableJSON())) - project_id = Column(PortableUUID(), ForeignKey("projects.id"), index=True) + portal_id = Column(PortableUUID(), ForeignKey("portals.id"), index=True, nullable=True) + project_id = Column(PortableUUID(), ForeignKey("projects.id"), index=True, nullable=True) dashboard_id = Column(PortableUUID(), ForeignKey("dashboards.id"), index=True) title = Column(Text, index=True) type = Column(Text, index=True) @@ -216,6 +243,7 @@ class WidgetConfig(Model, ModelMixin): widget = Column(Text, index=True) project = relationship("Project", back_populates="widget_configs") + portal = relationship("Portal", back_populates="widget_configs") class User(Model, ModelMixin): @@ -229,6 +257,7 @@ class User(Model, ModelMixin): group_id = Column(PortableUUID(), ForeignKey("groups.id"), index=True) dashboards = relationship("Dashboard") owned_projects = relationship("Project", backref="owner") + owned_portals = relationship("Portal", backref="owner") tokens = relationship("Token", backref="user") projects = relationship( "Project", secondary=users_projects, backref=backref("users", lazy="subquery") diff --git a/backend/ibutsu_server/db/upgrades.py b/backend/ibutsu_server/db/upgrades.py index e7a6a2c7..20a38516 100644 --- a/backend/ibutsu_server/db/upgrades.py +++ b/backend/ibutsu_server/db/upgrades.py @@ -7,7 +7,7 @@ from ibutsu_server.db.base import Boolean, Column, ForeignKey, Text from ibutsu_server.db.types import PortableUUID -__version__ = 5 +__version__ = 6 def get_upgrade_op(session): @@ -175,3 +175,78 @@ def upgrade_5(session): "projects", Column("default_dashboard_id", PortableUUID(), ForeignKey("dashboards.id")), ) + + +def upgrade_6(session): + """Version 6 upgrade + + This upgrade adds portals relationships + """ + engine = session.get_bind() + op = get_upgrade_op(session) + metadata = MetaData() + metadata.reflect(bind=engine) + + # WidgetConfig model changes + wc_table = metadata.tables.get("widget_configs") + wc_table_present = bool("widget_configs" in metadata.tables and wc_table is not None) + + # widgetconfig new column portal_id + if wc_table_present and "portal_id" not in [col.name for col in wc_table.columns]: + op.add_column( + "widget_configs", + Column( + "portal_id", PortableUUID(), ForeignKey("portals.id"), nullable=True, index=True + ), + ) + if engine.url.get_dialect().name != "sqlite": + # SQLite doesn't support ALTER TABLE ADD CONSTRAINT + op.create_foreign_key( + "fk_widget_configs_portal_id", + "widget_configs", + "portals", + ["portal_id"], + ["id"], + ) + + # widgetconfig alter project_id -> nullable + # can't alter tables in unittest with sqllite + # TODO replace sqlite for unit tests + if ( + engine.url.get_dialect().name != "sqlite" + and wc_table_present + and "project_id" in [col.name for col in wc_table.columns] + ): + op.alter_column("widget_configs", "project_id", nullable=True) + + # Dashboard model changes + dash_table = metadata.tables.get("dashboards") + dash_table_present = bool("dashboards" in metadata.tables and dash_table is not None) + + # dashboard alter project_id -> nullable + # can't alter tables in unittest with sqllite + # TODO replace sqlite for unit tests + if ( + engine.url.get_dialect().name != "sqlite" + and dash_table_present + and "project_id" in [col.name for col in dash_table.columns] + ): + op.alter_column("dashboards", "project_id", nullable=True) + + # dashboard new column portal_id + if dash_table_present and "portal_id" not in [col.name for col in dash_table.columns]: + op.add_column( + "dashboards", + Column( + "portal_id", PortableUUID(), ForeignKey("portals.id"), nullable=True, index=True + ), + ) + if engine.url.get_dialect().name != "sqlite": + # SQLite doesn't support ALTER TABLE ADD CONSTRAINT + op.create_foreign_key( + "fk_dashboards_portal_id", + "dashboards", + "portals", + ["portal_id"], + ["id"], + ) diff --git a/backend/ibutsu_server/openapi/openapi.yaml b/backend/ibutsu_server/openapi/openapi.yaml index 5b9a3507..273b7583 100644 --- a/backend/ibutsu_server/openapi/openapi.yaml +++ b/backend/ibutsu_server/openapi/openapi.yaml @@ -14,6 +14,8 @@ tags: name: run - description: A collection of test runs name: project + - description: Dashboard aggregation of results from multiple projects + name: portal - description: A group of projects name: group - description: A report @@ -583,6 +585,123 @@ paths: tags: - run x-openapi-router-controller: ibutsu_server.controllers.run_controller + /portal: + get: + operationId: get_portal_list + parameters: + - description: Fields to filter by + explode: true + in: query + name: filter + required: false + schema: + items: + type: string + type: array + style: form + - description: Filter portals by owner ID + explode: true + in: query + name: ownerId + required: false + schema: + type: string + style: form + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/PageSize' + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/PortalList' + description: Array of portals + summary: Get a list of portals + tags: + - portal + x-openapi-router-controller: ibutsu_server.controllers.portal_controller + post: + operationId: add_portal + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Portal' + description: Portal, a home for multi project dashboards + required: true + responses: + 201: + content: + application/json: + schema: + $ref: '#/components/schemas/Portal' + description: A portal was created + 400: + description: Bad request, JSON required + 409: + description: Bad request, portal ID already exists + summary: Create a portal + tags: + - portal + x-openapi-router-controller: ibutsu_server.controllers.portal_controller + /portal/{id}: + get: + operationId: get_portal + parameters: + - description: ID of portal to get + explode: false + in: path + name: id + required: true + schema: + type: string + style: simple + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/Portal' + description: Portal object + 404: + description: Portal not found + summary: Get a single portal by ID + tags: + - portal + x-openapi-router-controller: ibutsu_server.controllers.portal_controller + put: + operationId: update_portal + parameters: + - description: ID of portal to modify + explode: false + in: path + name: id + required: true + schema: + type: string + format: uuid + style: simple + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Portal' + description: Portal + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/Portal' + description: Portal object + 400: + description: Bad request, JSON required or not enough parameters + 404: + description: Portal not found + summary: Update a Portal + tags: + - portal + x-openapi-router-controller: ibutsu_server.controllers.portal_controller /project: get: operationId: get_project_list @@ -1298,6 +1417,9 @@ paths: tags: - widget-config x-openapi-router-controller: ibutsu_server.controllers.widget_config_controller + # https://github.com/OpenAPITools/openapi-generator/issues/8722 + # x-dependencies: + # - OnlyOne(portal_id, project_id); /widget-config/{id}: get: operationId: get_widget_config @@ -2086,6 +2208,152 @@ paths: tags: - admin/project management x-openapi-router-controller: ibutsu_server.controllers.admin.project_controller + /admin/portal: + get: + operationId: admin_get_portal_list + parameters: + - description: Fields to filter by + explode: true + in: query + name: filter + required: false + schema: + items: + type: string + type: array + style: form + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/PageSize' + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/PortalList' + description: Returns a list of portals + 401: + description: The user needs to be logged in + 403: + description: The user needs to be a superadmin + tags: + - admin/portal management + summary: Administration endpoint to return a list of portals. Only accessible to superadmins. + x-openapi-router-controller: ibutsu_server.controllers.admin.portal_controller + post: + operationId: admin_add_portal + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Portal' + description: A portal + responses: + 201: + content: + application/json: + schema: + $ref: '#/components/schemas/Portal' + description: A portal was created + 400: + description: Bad request, JSON required + 401: + description: The user needs to be logged in + 403: + description: The user needs to be a superadmin + tags: + - admin/portal management + summary: Administration endpoint to manually add a portal. Only accessible to superadmins. + x-openapi-router-controller: ibutsu_server.controllers.admin.portal_controller + /admin/portal/{id}: + get: + operationId: admin_get_portal + parameters: + - description: The id of a portal + explode: false + in: path + name: id + required: true + schema: + type: string + format: uuid + style: simple + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/Portal' + description: Returns a portal + 401: + description: The user needs to be logged in + 403: + description: The user needs to be a superadmin + 404: + description: The portal does not exist + tags: + - admin/portal management + summary: Administration endpoint to return a portal. Only accessible to superadmins. + x-openapi-router-controller: ibutsu_server.controllers.admin.portal_controller + put: + operationId: admin_update_portal + parameters: + - description: The ID of the portal to update + explode: false + in: path + name: id + required: true + schema: + type: string + format: uuid + style: simple + requestBody: + $ref: '#/components/requestBodies/Portal' + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/Portal' + description: successful operation + 400: + description: Bad reqest, JSON required or not enough parameters + 401: + description: The user needs to be logged in + 403: + description: The user needs to be a superadmin + 404: + description: Portal not found + summary: Administration endpoint to update a portal. Only accessible to superadmins. + tags: + - admin/portal management + x-openapi-router-controller: ibutsu_server.controllers.admin.portal_controller + delete: + operationId: admin_delete_portal + parameters: + - description: The ID of the portal to delete + explode: false + in: path + name: id + required: true + schema: + type: string + format: uuid + style: simple + responses: + 200: + description: The specified portal was deleted + 401: + description: The user needs to be logged in + 403: + description: The user needs to be a superadmin + 404: + description: Portal not found + summary: Administration endpoint to delete a portal. Only accessible to superadmins. + tags: + - admin/portal management + x-openapi-router-controller: ibutsu_server.controllers.admin.portal_controller + + components: requestBodies: Result: @@ -2185,6 +2453,11 @@ components: application/json: schema: $ref: '#/components/schemas/Project' + Portal: + content: + application/json: + schema: + $ref: '#/components/schemas/Portal' schemas: Result: example: @@ -2388,6 +2661,33 @@ components: description: The date this artifact was uploaded type: string type: object + Portal: + example: + id: fe5ad2dc-330a-11ef-9036-12b95372ee33 + name: my-portal + title: My portal + owner_id: 07a776ba-330b-11ef-9338-12b95372ee33 + properties: + id: + description: Unique ID of the portal + example: fe5ad2dc-330a-11ef-9036-12b95372ee33 + type: string + format: uuid + name: + description: The machine name of the portal + example: my-portal + type: string + title: + description: The human-readable title of the portal + example: My portal + type: string + owner_id: + description: The ID of the owner of this portal + example: 07a776ba-330b-11ef-9338-12b95372ee33 + type: string + format: uuid + nullable: true + type: object Project: example: id: 44941c55-9736-42f6-acce-ca3c4739d0f3 @@ -2573,11 +2873,13 @@ components: format: uuid type: object WidgetConfig: + # TODO schema doesn't support examples keyword, show with portal and with project example: id: afbcf5c7-1ffd-4367-b228-5a868c29e0ef type: widget widget: jenkins-heatmap project_id: 44941c55-9736-42f6-acce-ca3c4739d0f3 + portal_id: null weight: 0 params: job_name: integration_tests @@ -2600,10 +2902,17 @@ components: example: jenkins-heatmap type: string project_id: - description: The project ID for which the widget is designed + description: The project ID for the widget, exclusive with portal_id example: 44941c55-9736-42f6-acce-ca3c4739d0f3 type: string format: uuid + nullable: true + portal_id: + description: The portal ID for the widget, exclusive with project_id + example: fe5ad2dc-330a-11ef-9036-12b95372ee33 + type: string + format: uuid + nullable: true weight: description: The weighting for the widget, lower weight means it will display first example: 0 @@ -3063,6 +3372,26 @@ components: pagination: $ref: '#/components/schemas/Pagination' type: object + PortalList: + example: + portals: + - id: fe5ad2dc-330a-11ef-9036-12b95372ee33 + name: My Portal + title: my-portal + ownerId: 07a776ba-330b-11ef-9338-12b95372ee33 + pagination: + page: 2 + pageSize: 25 + totalPages: 10 + totalItems: 243 + properties: + projects: + items: + $ref: '#/components/schemas/Portal' + type: array + pagination: + $ref: '#/components/schemas/Pagination' + type: object ProjectList: example: projects: diff --git a/backend/ibutsu_server/test/__init__.py b/backend/ibutsu_server/test/__init__.py index 9d1e24e3..1a398fac 100644 --- a/backend/ibutsu_server/test/__init__.py +++ b/backend/ibutsu_server/test/__init__.py @@ -34,6 +34,9 @@ def _wrapped(*args, **kwargs): return decorate +MESSAGE = "Response body is : %s" + + class BaseTestCase(TestCase): def create_app(self): logging.getLogger("connexion.operation").setLevel("ERROR") @@ -58,7 +61,17 @@ def create_app(self): self.test_user = User(name="Test User", email="test@example.com", is_active=True) session.add(self.test_user) session.commit() + + # store the jwt_token and standardized headers for use in tests self.jwt_token = generate_token(self.test_user.id) + self.headers_no_content = { + "Accept": "application/json", + "Authorization": f"Bearer {self.jwt_token}", + } + self.headers = self.headers_no_content.copy() + self.headers.update({"Content-Type": "application/json"}) + + # create the login token and commit to session token = Token(name="login-token", user=self.test_user, token=self.jwt_token) session.add(token) session.commit() @@ -68,13 +81,26 @@ def create_app(self): ibutsu_server.tasks.task = mock_task return app + # Override these assert functions from TestCase so we can set a helpful default message + def assert_200(self, response, message=None): + """ + Checks if response status code is 200 + :param response: Flask response + :param message: Message to display on test failure + """ + self.assert_status( + response, HTTPStatus.OK, message or MESSAGE.format(response.data.decode("utf-8")) + ) + def assert_201(self, response, message=None): """ Checks if response status code is 201 :param response: Flask response :param message: Message to display on test failure """ - self.assert_status(response, HTTPStatus.CREATED, message) + self.assert_status( + response, HTTPStatus.CREATED, message or MESSAGE.format(response.data.decode("utf-8")) + ) def assert_503(self, response, message=None): """ @@ -82,7 +108,43 @@ def assert_503(self, response, message=None): :param response: Flask response :param message: Message to display on test failure """ - self.assert_status(response, HTTPStatus.SERVICE_UNAVAILABLE, message) + self.assert_status( + response, + HTTPStatus.SERVICE_UNAVAILABLE, + message or MESSAGE.format(response.data.decode("utf-8")), + ) + + def assert_400(self, response, message=None): + """ + Checks if response code is 400 + :param response: Flask response + :param message: message to display on test failure + """ + self.assert_status( + response, + HTTPStatus.BAD_REQUEST, + message or MESSAGE.format(response.data.decode("utf-8")), + ) + + def assert_404(self, response, message=None): + """ + Checks if response code is 404 + :param response: Flask response + :param message: message to display on test failure + """ + self.assert_status( + response, HTTPStatus.NOT_FOUND, message or MESSAGE.format(response.data.decode("utf-8")) + ) + + def assert_409(self, response, message=None): + """ + Checks if response code is 409 + :param response: Flask response + :param message: message to display on test failure + """ + self.assert_status( + response, HTTPStatus.CONFLICT, message or MESSAGE.format(response.data.decode("utf-8")) + ) def assert_equal(self, first, second, msg=None): """Alias""" @@ -156,6 +218,10 @@ class MockProject(MockModel): COLUMNS = ["id", "name", "title", "owner_id", "group_id", "users"] +class MockPortal(MockModel): + COLUMNS = ["id", "name", "title", "owner_id", "default_dashboard_id"] + + class MockResult(MockModel): COLUMNS = [ "id", @@ -207,6 +273,7 @@ class MockRun(MockModel): class MockDashboard(MockModel): + # TODO: dashboard columns and unit test coverage COLUMNS = [] @@ -215,12 +282,14 @@ class MockWidgetConfig(MockModel): "id", "navigable", "params", + "portal_id", "project_id", "dashboard_id", "title", "type", "weight", "widget", + "portal", "project", "dashboard", ] diff --git a/backend/ibutsu_server/test/test_artifact_controller.py b/backend/ibutsu_server/test/test_artifact_controller.py index 0ea33f26..55ebfb41 100644 --- a/backend/ibutsu_server/test/test_artifact_controller.py +++ b/backend/ibutsu_server/test/test_artifact_controller.py @@ -59,46 +59,38 @@ def test_delete_artifact(self): Delete an artifact """ - headers = {"Authorization": f"Bearer {self.jwt_token}"} + headers = {"Authorization": self.headers.get("Authorization")} response = self.client.open(f"/api/artifact/{MOCK_ID}", method="DELETE", headers=headers) self.mock_artifact.query.get.assert_called_once_with(MOCK_ID) self.mock_session.delete.assert_called_once_with(MOCK_ARTIFACT) self.mock_session.commit.assert_called_once() - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_200(response) def test_download_artifact(self): """Test case for download_artifact Download an artifact """ - headers = { - "Accept": "application/octet-stream", - "Authorization": f"Bearer {self.jwt_token}", - } response = self.client.open( f"/api/artifact/{MOCK_ID}/download", method="GET", - headers=headers, + headers=self.headers_no_content, ) self.mock_artifact.query.get.assert_called_once_with(MOCK_ID) - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_200(response) def test_get_artifact(self): """Test case for get_artifact Get a single artifact """ - headers = { - "Accept": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } response = self.client.open( f"/api/artifact/{MOCK_ID}", method="GET", - headers=headers, + headers=self.headers_no_content, ) self.mock_artifact.query.get.assert_called_once_with(MOCK_ID) - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_200(response) def test_get_artifact_list(self): """Test case for get_artifact_list @@ -106,15 +98,15 @@ def test_get_artifact_list(self): Get a (filtered) list of artifacts """ query_string = [("resultId", MOCK_RESULT_ID), ("page", 56), ("pageSize", 56)] - headers = { - "Accept": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } + response = self.client.open( - "/api/artifact", method="GET", headers=headers, query_string=query_string + "/api/artifact", + method="GET", + headers=self.headers_no_content, + query_string=query_string, ) self.mock_limit.return_value.offset.return_value.all.assert_called_once() - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_200(response) @skip("Something is getting crossed in the validation layer") def test_upload_artifact(self): @@ -122,11 +114,12 @@ def test_upload_artifact(self): Uploads a test run artifact """ - headers = { - "Accept": "application/json", - "Content-Type": "multipart/form-data", - "Authorization": f"Bearer {self.jwt_token}", - } + headers = self.headers.copy() + headers.update( + { + "Content-Type": "multipart/form-data", + } + ) data = { "resultId": MOCK_ID, "filename": "log.txt", @@ -140,4 +133,4 @@ def test_upload_artifact(self): data=data, content_type="multipart/form-data", ) - self.assert_201(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_201(response) diff --git a/backend/ibutsu_server/test/test_group_controller.py b/backend/ibutsu_server/test/test_group_controller.py index 677a7589..955d40cc 100644 --- a/backend/ibutsu_server/test/test_group_controller.py +++ b/backend/ibutsu_server/test/test_group_controller.py @@ -36,16 +36,11 @@ def test_add_group(self): Create a new group """ - headers = { - "Accept": "application/json", - "Content-Type": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } self.mock_group.query.get.return_value = None response = self.client.open( "/api/group", method="POST", - headers=headers, + headers=self.headers, data=json.dumps({"name": "Example group"}), content_type="application/json", ) @@ -57,11 +52,9 @@ def test_get_group(self): Get a group """ - headers = { - "Accept": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } - response = self.client.open(f"/api/group/{MOCK_ID}", method="GET", headers=headers) + response = self.client.open( + f"/api/group/{MOCK_ID}", method="GET", headers=self.headers_no_content + ) self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) self.assert_equal(response.json, MOCK_GROUP_DICT) @@ -71,12 +64,8 @@ def test_get_group_list(self): Get a list of groups """ query_string = [("page", 56), ("pageSize", 56)] - headers = { - "Accept": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } response = self.client.open( - "/api/group", method="GET", headers=headers, query_string=query_string + "/api/group", method="GET", headers=self.headers_no_content, query_string=query_string ) self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) self.assert_equal( @@ -97,15 +86,11 @@ def test_update_group(self): Update a group """ - headers = { - "Accept": "application/json", - "Content-Type": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } + response = self.client.open( f"/api/group/{MOCK_ID}", method="PUT", - headers=headers, + headers=self.headers, data=json.dumps({"name": "Changed name"}), content_type="application/json", ) diff --git a/backend/ibutsu_server/test/test_health_controller.py b/backend/ibutsu_server/test/test_health_controller.py index cd3b85ca..ce0e002f 100644 --- a/backend/ibutsu_server/test/test_health_controller.py +++ b/backend/ibutsu_server/test/test_health_controller.py @@ -11,24 +11,18 @@ def test_get_database_health(self): Get a health report for the database """ - headers = { - "Accept": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } - response = self.client.open("/api/health/database", method="GET", headers=headers) - self.assert_503(response, "Response body is : " + response.data.decode("utf-8")) + response = self.client.open( + "/api/health/database", method="GET", headers=self.headers_no_content + ) + self.assert_503(response) def test_get_health(self): """Test case for get_health Get a general health report """ - headers = { - "Accept": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } - response = self.client.open("/api/health", method="GET", headers=headers) - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) + response = self.client.open("/api/health", method="GET", headers=self.headers_no_content) + self.assert_200(response) if __name__ == "__main__": diff --git a/backend/ibutsu_server/test/test_login_controller.py b/backend/ibutsu_server/test/test_login_controller.py index af380172..dae8b7f2 100644 --- a/backend/ibutsu_server/test/test_login_controller.py +++ b/backend/ibutsu_server/test/test_login_controller.py @@ -28,6 +28,9 @@ def setUp(self): self.mock_user = self.user_patcher.start() self.mock_user.query.filter_by.return_value.first.return_value = MOCK_USER + self.headers_no_auth = self.headers.copy() + self.headers_no_auth.pop("Authorization") + def tearDown(self): """Teardown the mocks""" self.user_patcher.stop() @@ -46,15 +49,15 @@ def test_login(self, mocked_generate_token): "email": MOCK_EMAIL, "token": expected_token, } - headers = {"Accept": "application/json", "Content-Type": "application/json"} + response = self.client.open( "/api/login", method="POST", - headers=headers, + headers=self.headers_no_auth, data=json.dumps(login_details), content_type="application/json", ) - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_200(response) assert response.json == expected_response def test_login_empty_request(self): @@ -69,15 +72,14 @@ def test_login_empty_request(self): "title": HTTPStatus.BAD_REQUEST.phrase, "type": "about:blank", } - headers = {"Accept": "application/json", "Content-Type": "application/json"} response = self.client.open( "/api/login", method="POST", - headers=headers, + headers=self.headers_no_auth, data=json.dumps(login_details), content_type="application/json", ) - self.assert_400(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_400(response) assert response.json == expected_response def test_login_no_user(self): @@ -90,16 +92,15 @@ def test_login_no_user(self): "code": "INVALID", "message": "Username and/or password are invalid", } - headers = {"Accept": "application/json", "Content-Type": "application/json"} self.mock_user.query.filter_by.return_value.first.return_value = None response = self.client.open( "/api/login", method="POST", - headers=headers, + headers=self.headers_no_auth, data=json.dumps(login_details), content_type="application/json", ) - self.assert_401(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_401(response) assert response.json == expected_response def test_login_bad_password(self): @@ -112,15 +113,14 @@ def test_login_bad_password(self): "code": "INVALID", "message": "Username and/or password are invalid", } - headers = {"Accept": "application/json", "Content-Type": "application/json"} response = self.client.open( "/api/login", method="POST", - headers=headers, + headers=self.headers_no_auth, data=json.dumps(login_details), content_type="application/json", ) - self.assert_401(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_401(response) assert response.json == expected_response def test_support(self): @@ -133,14 +133,13 @@ def test_support(self): "facebook": False, "gitlab": True, } - headers = {"Accept": "application/json", "Content-Type": "application/json"} response = self.client.open( "/api/login/support", method="GET", - headers=headers, + headers=self.headers_no_auth, content_type="application/json", ) - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_200(response) assert response.json == expected_response def test_config_gitlab(self): @@ -151,12 +150,11 @@ def test_config_gitlab(self): "redirect_uri": f"http://{LOCALHOST}:8080/api/login/auth/gitlab", "scope": "read_user", } - headers = {"Accept": "application/json", "Content-Type": "application/json"} response = self.client.open( "/api/login/config/gitlab", method="GET", - headers=headers, + headers=self.headers_no_auth, content_type="application/json", ) - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_200(response) assert response.json == expected_response diff --git a/backend/ibutsu_server/test/test_portal_controller.py b/backend/ibutsu_server/test/test_portal_controller.py new file mode 100644 index 00000000..f6ee5818 --- /dev/null +++ b/backend/ibutsu_server/test/test_portal_controller.py @@ -0,0 +1,155 @@ +from unittest.mock import MagicMock, patch + +from flask import json + +from ibutsu_server.test import BaseTestCase, MockPortal, MockUser + +MOCK_ID = "f40991a6-3305-11ef-9083-12b95372ee33" +MOCK_USER_ID = "0eea178e-3306-11ef-b969-12b95372ee33" + +MOCK_PORTAL = MockPortal( + id=MOCK_ID, + name="unittest-portal", + title="UnitTest Portal", + owner_id=MOCK_USER_ID, + default_dashboard_id="7e8a1684-3306-11ef-8433-12b95372ee33", +) +MOCK_USER = MockUser.from_dict(**{"id": MOCK_USER_ID}) + + +class TestPortalController(BaseTestCase): + """PortalController integration test stubs""" + + def setUp(self): + """Set up a test data""" + MOCK_PORTAL.owner = self.test_user + self.session_patcher = patch("ibutsu_server.controllers.portal_controller.session") + self.mock_session = self.session_patcher.start() + + mock_offset = MagicMock() + mock_offset.limit.return_value.all.return_value = [MOCK_PORTAL] + + # mock portal return values for the DB query, and dict ctor + self.portal_patcher = patch("ibutsu_server.controllers.portal_controller.Portal") + self.mock_portal = self.portal_patcher.start() + self.mock_portal.query.get.return_value = MOCK_PORTAL + self.mock_portal.query.count.return_value = 1 + self.mock_portal.from_dict.return_value = MOCK_PORTAL + + # mock the offset for listing to return the only portal instance + self.mock_portal.query.offset.return_value = mock_offset + + def tearDown(self): + """Teardown the mocks""" + self.portal_patcher.stop() + self.session_patcher.stop() + + def test_add_portal(self): + """Test case for add_portal + + Create a portal + """ + self.user_patcher = patch("ibutsu_server.controllers.portal_controller.User") + self.mock_user = self.user_patcher.start() + self.mock_user.query.get.return_value = MOCK_USER + # clear the setUp mock for the query since we're adding it here + self.mock_portal.query.get.return_value = None + + response = self.client.open( + "/api/portal", + method="POST", + headers=self.headers, + data=json.dumps(MOCK_PORTAL.to_dict()), + content_type="application/json", + ) + self.assert_201(response) + self.assert_equal(response.json, MOCK_PORTAL.to_dict()) + self.mock_session.add.assert_called_once_with(MOCK_PORTAL) + self.mock_session.commit.assert_called_once() + # teardown user patcher + self.user_patcher.stop() + + def test_add_portal_409(self): + """Test case to hit a conflict on add""" + # MOCK_PORTAL is already there from setup, just try it again + response = self.client.open( + "/api/portal", + method="POST", + headers=self.headers, + data=json.dumps(MOCK_PORTAL.to_dict()), + content_type="application/json", + ) + self.assert_409(response) + + def test_get_portal_by_id(self): + """Test case for get_portal + + Get a single portal by ID + """ + self.mock_portal.query.filter.return_value.first.return_value = None + + response = self.client.open( + f"/api/portal/{MOCK_ID}", method="GET", headers=self.headers_no_content + ) + self.assert_200(response) + self.assert_equal(response.json, MOCK_PORTAL.to_dict()) + self.mock_portal.query.get.assert_called_once_with(MOCK_ID) + + def test_get_portal_by_name(self): + """Test case for get_portal + + Get a single portal by name + """ + self.mock_portal.query.filter.return_value.first.return_value = MOCK_PORTAL + + response = self.client.open( + f"/api/portal/{MOCK_ID}", method="GET", headers=self.headers_no_content + ) + self.assert_200(response) + self.assert_equal(response.json, MOCK_PORTAL.to_dict()) + + def test_get_portal_list(self): + """Test case for get_portal_list + + Get a list of portals + """ + query_string = [ + ("page", 56), + ("pageSize", 56), + ] + + response = self.client.open( + "/api/portal", method="GET", headers=self.headers_no_content, query_string=query_string + ) + self.assert_200(response) + expected_response = { + "pagination": { + "page": 56, + "pageSize": 56, + "totalItems": 1, + "totalPages": 1, + }, + "portals": [MOCK_PORTAL.to_dict()], + } + assert response.json == expected_response + + def test_update_portal(self): + """Test case for update_portal + + Update a portal + """ + updates = { + "owner_id": "dd338937-95f0-4b4e-a7a4-0d02da9f56e6", + } + updated_dict = MOCK_PORTAL.to_dict().copy() + updated_dict.update(updates) + + response = self.client.open( + f"/api/portal/{MOCK_ID}", + method="PUT", + headers=self.headers, + data=json.dumps(updates), + content_type="application/json", + ) + self.assert_200(response) + self.assert_equal(response.json, updated_dict) diff --git a/backend/ibutsu_server/test/test_project_controller.py b/backend/ibutsu_server/test/test_project_controller.py index 0a18da68..9e387a6b 100644 --- a/backend/ibutsu_server/test/test_project_controller.py +++ b/backend/ibutsu_server/test/test_project_controller.py @@ -64,19 +64,14 @@ def test_add_project(self): self.mock_user.query.get.return_value = MOCK_USER self.mock_project.query.get.return_value = None - headers = { - "Accept": "application/json", - "Content-Type": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } response = self.client.open( "/api/project", method="POST", - headers=headers, + headers=self.headers, data=json.dumps(MOCK_DATA), content_type="application/json", ) - self.assert_201(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_201(response) self.assert_equal(response.json, MOCK_PROJECT_DICT) self.mock_session.add.assert_called_once_with(MOCK_PROJECT) self.mock_session.commit.assert_called_once() @@ -89,12 +84,11 @@ def test_get_project_by_id(self): Get a single project by ID """ self.mock_project.query.filter.return_value.first.return_value = None - headers = { - "Accept": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } - response = self.client.open(f"/api/project/{MOCK_ID}", method="GET", headers=headers) - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) + + response = self.client.open( + f"/api/project/{MOCK_ID}", method="GET", headers=self.headers_no_content + ) + self.assert_200(response) self.assert_equal(response.json, MOCK_PROJECT_DICT) self.mock_project.query.get.assert_called_once_with(MOCK_ID) @@ -104,12 +98,11 @@ def test_get_project_by_name(self): Get a single project by name """ self.mock_project.query.filter.return_value.first.return_value = MOCK_PROJECT - headers = { - "Accept": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } - response = self.client.open(f"/api/project/{MOCK_ID}", method="GET", headers=headers) - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) + + response = self.client.open( + f"/api/project/{MOCK_ID}", method="GET", headers=self.headers_no_content + ) + self.assert_200(response) self.assert_equal(response.json, MOCK_PROJECT_DICT) def test_get_project_list(self): @@ -121,14 +114,10 @@ def test_get_project_list(self): ("page", 56), ("pageSize", 56), ] - headers = { - "Accept": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } response = self.client.open( - "/api/project", method="GET", headers=headers, query_string=query_string + "/api/project", method="GET", headers=self.headers_no_content, query_string=query_string ) - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_200(response) expected_response = { "pagination": { "page": 56, @@ -151,17 +140,13 @@ def test_update_project(self): } updated_dict = MOCK_PROJECT_DICT.copy() updated_dict.update(updates) - headers = { - "Accept": "application/json", - "Content-Type": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } + response = self.client.open( f"/api/project/{MOCK_ID}", method="PUT", - headers=headers, + headers=self.headers, data=json.dumps(updates), content_type="application/json", ) - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_200(response) self.assert_equal(response.json, updated_dict) diff --git a/backend/ibutsu_server/test/test_report_controller.py b/backend/ibutsu_server/test/test_report_controller.py index 9ab9c45a..24dac018 100644 --- a/backend/ibutsu_server/test/test_report_controller.py +++ b/backend/ibutsu_server/test/test_report_controller.py @@ -43,20 +43,16 @@ def test_add_report(self): Create a new report """ body = {"type": "csv", "source": "local"} - headers = { - "Accept": "application/json", - "Content-Type": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } + with patch.dict("ibutsu_server.controllers.report_controller.REPORTS", {"csv": MOCK_CSV}): response = self.client.open( "/api/report", method="POST", - headers=headers, + headers=self.headers, data=json.dumps(body), content_type="application/json", ) - self.assert_201(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_201(response) assert response.json == MOCK_REPORT_DICT def test_get_report(self): @@ -64,12 +60,10 @@ def test_get_report(self): Get a report """ - headers = { - "Accept": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } - response = self.client.open(f"/api/report/{MOCK_ID}", method="GET", headers=headers) - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) + response = self.client.open( + f"/api/report/{MOCK_ID}", method="GET", headers=self.headers_no_content + ) + self.assert_200(response) assert response.json == MOCK_REPORT_DICT def test_get_report_list(self): @@ -82,18 +76,18 @@ def test_get_report_list(self): mock_limit.return_value.all.return_value = [MOCK_REPORT] self.mock_report.query.order_by.return_value.offset.return_value.limit = mock_limit query_string = [("page", 56), ("pageSize", 56)] - headers = { - "Accept": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } + with patch( "ibutsu_server.controllers.report_controller.get_project_id" ) as mocked_get_project_id: mocked_get_project_id.return_value = None response = self.client.open( - "/api/report", method="GET", headers=headers, query_string=query_string + "/api/report", + method="GET", + headers=self.headers_no_content, + query_string=query_string, ) - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_200(response) expected_response = { "pagination": { "page": 56, diff --git a/backend/ibutsu_server/test/test_result_controller.py b/backend/ibutsu_server/test/test_result_controller.py index 1b6a89a5..6b1f69ae 100644 --- a/backend/ibutsu_server/test/test_result_controller.py +++ b/backend/ibutsu_server/test/test_result_controller.py @@ -101,19 +101,15 @@ def test_add_result(self): """ result = ADDED_RESULT.to_dict() self.mock_result.query.get.return_value = None - headers = { - "Accept": "application/json", - "Content-Type": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } + response = self.client.open( "/api/result", method="POST", - headers=headers, + headers=self.headers, data=json.dumps(result), content_type="application/json", ) - self.assert_201(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_201(response) assert response.json == MOCK_RESULT_DICT def test_get_result(self): @@ -121,12 +117,11 @@ def test_get_result(self): Get a single result """ - headers = { - "Accept": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } - response = self.client.open(f"/api/result/{MOCK_ID}", method="GET", headers=headers) - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) + + response = self.client.open( + f"/api/result/{MOCK_ID}", method="GET", headers=self.headers_no_content + ) + self.assert_200(response) assert response.json == MOCK_RESULT_DICT def test_get_result_list(self): @@ -144,14 +139,11 @@ def test_get_result_list(self): ("page", 56), ("pageSize", 56), ] - headers = { - "Accept": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } + response = self.client.open( - "/api/result", method="GET", headers=headers, query_string=query_string + "/api/result", method="GET", headers=self.headers_no_content, query_string=query_string ) - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_200(response) expected_response = { "pagination": { "page": 56, @@ -178,17 +170,13 @@ def test_update_result(self): "source": "source_updated", "test_id": "test_id_updated", } - headers = { - "Accept": "application/json", - "Content-Type": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } + response = self.client.open( f"/api/result/{MOCK_ID}", method="PUT", - headers=headers, + headers=self.headers, data=json.dumps(result), content_type="application/json", ) - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_200(response) assert response.json == UPDATED_RESULT.to_dict() diff --git a/backend/ibutsu_server/test/test_run_controller.py b/backend/ibutsu_server/test/test_run_controller.py index b7193736..3b57d35f 100644 --- a/backend/ibutsu_server/test/test_run_controller.py +++ b/backend/ibutsu_server/test/test_run_controller.py @@ -78,20 +78,15 @@ def test_add_run(self): "start_time": START_TIME, "created": START_TIME, } - headers = { - "Accept": "application/json", - "Content-Type": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } response = self.client.open( "/api/run", method="POST", - headers=headers, + headers=self.headers, data=json.dumps(run_dict), content_type="application/json", ) self.project_patcher.stop() - self.assert_201(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_201(response) resp = response.json.copy() resp["project"] = None self.assert_equal(resp, MOCK_RUN_DICT) @@ -102,12 +97,11 @@ def test_get_run(self): Get a single run by ID """ - headers = { - "Accept": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } - response = self.client.open(f"/api/run/{MOCK_ID}", method="GET", headers=headers) - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) + + response = self.client.open( + f"/api/run/{MOCK_ID}", method="GET", headers=self.headers_no_content + ) + self.assert_200(response) resp = response.json.copy() resp["project"] = None self.assert_equal(resp, MOCK_RUN_DICT) @@ -118,14 +112,11 @@ def test_get_run_list(self): Get a list of the test runs """ query_string = [("page", 56), ("pageSize", 56)] - headers = { - "Accept": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } + response = self.client.open( - "/api/run", method="GET", headers=headers, query_string=query_string + "/api/run", method="GET", headers=self.headers_no_content, query_string=query_string ) - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_200(response) @skip("multipart/form-data not supported by Connexion") def test_import_run(self): @@ -133,20 +124,16 @@ def test_import_run(self): Import a JUnit XML file """ - headers = { - "Accept": "application/json", - "Content-Type": "multipart/form-data", - "Authorization": f"Bearer {self.jwt_token}", - } + data = dict(xml_file=(BytesIO(b"some file data"), "file.txt")) response = self.client.open( "/api/run/import", method="POST", - headers=headers, + headers=self.headers, data=data, content_type="multipart/form-data", ) - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_200(response) def test_update_run(self): """Test case for update_run @@ -157,20 +144,16 @@ def test_update_run(self): "duration": 540.05433, "summary": {"errors": 1, "failures": 3, "skips": 0, "tests": 548}, } - headers = { - "Accept": "application/json", - "Content-Type": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } + response = self.client.open( f"/api/run/{MOCK_ID}", method="PUT", - headers=headers, + headers=self.headers, data=json.dumps(run_dict), content_type="application/json", ) self.mock_update_run_task.apply_async.assert_called_once_with((MOCK_ID,), countdown=5) - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_200(response) resp = response.json.copy() resp["project"] = None self.assert_equal(resp, MOCK_RUN_DICT) diff --git a/backend/ibutsu_server/test/test_widget_config_controller.py b/backend/ibutsu_server/test/test_widget_config_controller.py index 225c0ebf..1cbb3e42 100644 --- a/backend/ibutsu_server/test/test_widget_config_controller.py +++ b/backend/ibutsu_server/test/test_widget_config_controller.py @@ -2,28 +2,33 @@ from flask import json -from ibutsu_server.test import BaseTestCase, MockWidgetConfig - -# from ibutsu_server.test import MockDashboard -# from ibutsu_server.test import MockProject +from ibutsu_server.test import BaseTestCase, MockPortal, MockProject, MockWidgetConfig MOCK_ID = "91e750be-2ef2-4d85-a50e-2c9366cefd9f" MOCK_PROJECT_ID = "5ac7d645-45a3-4cbe-acb2-c8d6f7e05468" +MOCK_PORTAL_ID = "c2ae891a-33c6-11ef-b032-12b95372ee33" MOCK_DASHBOARD_ID = "5af74747-3b75-4b00-afc3-6304c6f255d7" -MOCK_WIDGET_CONFIG = MockWidgetConfig( - id=MOCK_ID, - navigable=False, - params={}, - title="Stage builds", - type="widget", - weight=0, - widget="jenkins-heatmap", - project_id=MOCK_PROJECT_ID, - dashboard_id=MOCK_DASHBOARD_ID, + + +MOCK_PORTAL = MockPortal.from_dict( + id=MOCK_PORTAL_ID, + name="unittest-portal", + title="UnitTest Portal", + owner_id="0eea178e-3306-11ef-b969-12b95372ee33", + default_dashboard_id="7e8a1684-3306-11ef-8433-12b95372ee33", ) -MOCK_WIDGET_CONFIG_DICT = MOCK_WIDGET_CONFIG.to_dict() -# the result to be POST'ed to Ibutsu, we expect it to transformed into MOCK_RESULT -ADDED_WIDGET_CONFIG = MockWidgetConfig( + +MOCK_PROJECT = MockProject.from_dict( + id=MOCK_PROJECT_ID, + name="my-project", + title="My Project", + owner_id="8f22a434-b160-41ed-b700-0cc3d7f146b1", + group_id="9af34437-047c-48a5-bd21-6430e4532414", + users=[], +) + +# missing project/portal, gets injected at subTest +MOCK_WIDGET_CONFIG = MockWidgetConfig( id=MOCK_ID, navigable=False, params={}, @@ -31,20 +36,64 @@ type="widget", weight=0, widget="jenkins-heatmap", - project_id=MOCK_PROJECT_ID, dashboard_id=MOCK_DASHBOARD_ID, ) -UPDATED_WIDGET_CONFIG = MockWidgetConfig( - id=MOCK_ID, - navigable=False, - params={"jenkins_job_name": "stage"}, - title="Stage builds", - type="widget", - weight=10, - widget="jenkins-heatmap", - project_id=MOCK_PROJECT_ID, - dashboard_id=MOCK_DASHBOARD_ID, + +# create a complete config with project_id set +INTERIM = MOCK_WIDGET_CONFIG.to_dict() +INTERIM.update({"project_id": MOCK_PROJECT_ID}) +MOCK_COMPLETE_WIDGET_CONFIG = MockWidgetConfig.from_dict(**INTERIM) + + +# create an updated config with default weight and a param added +INTERIM = MOCK_COMPLETE_WIDGET_CONFIG.to_dict() +INTERIM.update( + { + "params": { + "jenkins_job_name": "stage", + }, # value given to PUT call + "weight": 10, # default weight applied on update automatically + } ) +MOCK_UPDATED_WIDGET_CONFIG = MockWidgetConfig.from_dict(**INTERIM) + + +VALID_PROJECT_PORTAL_COMBOS = [ + {"portal_id": MOCK_PORTAL_ID, "portal": None, "project_id": None, "project": None}, + {"portal_id": None, "portal": None, "project_id": MOCK_PROJECT_ID, "project": None}, + {"portal_id": None, "portal": MOCK_PORTAL.name, "project_id": None, "project": None}, + {"portal_id": None, "portal": None, "project_id": None, "project": MOCK_PROJECT.name}, +] + +# one of portal, portal_id, project, or project_id should be set +INVALID_WIDGET_CONFIG_COMBOS = [ + { + "portal_id": MOCK_PORTAL_ID, + "portal": None, + "project_id": None, + "project": {"dummy": "project"}, # the controller should raise before using this + }, + { + "portal_id": MOCK_PORTAL_ID, + "portal": None, + "project_id": MOCK_PROJECT_ID, + "project": None, + }, + { + "portal_id": None, + "portal": {"dummy": "portal"}, # the controller should raise before using this + "project_id": MOCK_PROJECT_ID, + "project": None, + }, + { + "portal_id": None, + "portal": {"dummy": "portal"}, # the controller should raise before using this, + "project_id": None, + "project": {"dummy": "project"}, # the controller should raise before using this + }, + {"portal_id": None, "portal": None, "project_id": None, "project": None}, + {"portal_id": MOCK_PORTAL_ID, "widget": "junk-widget-type"}, +] class TestWidgetConfigController(BaseTestCase): @@ -52,20 +101,30 @@ class TestWidgetConfigController(BaseTestCase): def setUp(self): """Set up tests""" + # mock the db session self.session_patcher = patch("ibutsu_server.controllers.widget_config_controller.session") self.mock_session = self.session_patcher.start() + # mock the project_has_user return self.project_has_user_patcher = patch( "ibutsu_server.controllers.widget_config_controller.project_has_user" ) self.mock_project_has_user = self.project_has_user_patcher.start() self.mock_project_has_user.return_value = True + # mock the WidgetConfig class self.widget_config_patcher = patch( "ibutsu_server.controllers.widget_config_controller.WidgetConfig" ) self.mock_widget_config = self.widget_config_patcher.start() - self.mock_widget_config.return_value = MOCK_WIDGET_CONFIG - self.mock_widget_config.query.get.return_value = MOCK_WIDGET_CONFIG - self.mock_widget_config.from_dict.return_value = ADDED_WIDGET_CONFIG + # mock the get_portal response + self.get_portal_patcher = patch( + "ibutsu_server.controllers.widget_config_controller.get_portal" + ) + self.mock_get_portal = self.get_portal_patcher.start() + # mock the get_project response + self.get_project_patcher = patch( + "ibutsu_server.controllers.widget_config_controller.get_project" + ) + self.mock_get_project = self.get_project_patcher.start() def tearDown(self): """Teardown the mocks""" @@ -73,53 +132,115 @@ def tearDown(self): self.project_has_user_patcher.stop() self.session_patcher.stop() + def mock_widget_config_returns( + self, config_dict=None, from_dict_obj=None, portal=None, project=None + ): + self.mock_widget_config.return_value = config_dict or MOCK_COMPLETE_WIDGET_CONFIG.to_dict() + self.mock_widget_config.query.get.return_value = ( + from_dict_obj or MOCK_COMPLETE_WIDGET_CONFIG + ) + self.mock_widget_config.from_dict.return_value = ( + from_dict_obj or MOCK_COMPLETE_WIDGET_CONFIG + ) + self.mock_get_portal.return_value = portal + self.mock_get_project.return_value = project + def test_add_widget_config(self): """Test case for add_widget_config Create a test widget_config """ - widget_config = ADDED_WIDGET_CONFIG.to_dict() - self.mock_widget_config.query.get.return_value = None - headers = { - "Accept": "application/json", - "Content-Type": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } - response = self.client.open( - "/api/widget-config", - method="POST", - headers=headers, - data=json.dumps(widget_config), - content_type="application/json", - ) - self.assert_201(response, "Response body is : " + response.data.decode("utf-8")) - assert response.json == MOCK_WIDGET_CONFIG_DICT + + for config in VALID_PROJECT_PORTAL_COMBOS: + widget_config_dict = MOCK_COMPLETE_WIDGET_CONFIG.to_dict() + with self.subTest(config=config): + # overwrite project/portal/project_id/portal_id with subtest config + widget_config_dict.update(config) + # run the updated dict through from_dict.to_dict and mock the return to None + MOCK_WC_WITH_PROJECT_PORTAL = MockWidgetConfig.from_dict(**widget_config_dict) + # Figure out whether to patch the get_portal or get_project function + PORTAL_OR_PROJ = {} + if config.get("portal") is not None: + PORTAL_OR_PROJ = {"portal": MOCK_PORTAL} + if config.get("project") is not None: + PORTAL_OR_PROJ = {"project": MOCK_PROJECT} + # Inject the mock return values based on subTest config + self.mock_widget_config_returns( + MOCK_WC_WITH_PROJECT_PORTAL.to_dict(), + MOCK_WC_WITH_PROJECT_PORTAL, + **PORTAL_OR_PROJ, + ) + self.mock_widget_config.query.get.return_value = None + + response = self.client.open( + "/api/widget-config", + method="POST", + headers=self.headers, + data=json.dumps(widget_config_dict), + content_type="application/json", + ) + self.assert_201(response) + assert response.json == MOCK_WC_WITH_PROJECT_PORTAL.to_dict() + widget_config_dict = MOCK_COMPLETE_WIDGET_CONFIG.to_dict() + + # TODO: test 403 on add + + def test_add_widget_invalid_config_bad_request(self): + """Test case for adding invalid widget config to produce BAD REQUEST + + Should not create a WC + """ + + for config in INVALID_WIDGET_CONFIG_COMBOS: + # use the incomplete mock to inject project/portal/ids + widget_config_dict = MOCK_WIDGET_CONFIG.to_dict() + + with self.subTest(config=config): + widget_config_dict.update(config) + MOCK_WIDGET_CONFIG_FULL = MockWidgetConfig.from_dict(**widget_config_dict) + self.mock_widget_config_returns( + MOCK_WIDGET_CONFIG_FULL.to_dict(), + MOCK_WIDGET_CONFIG_FULL, + ) + self.mock_widget_config.query.get.return_value = None + + response = self.client.open( + "/api/widget-config", + method="POST", + headers=self.headers, + data=json.dumps(widget_config_dict), + content_type="application/json", + ) + + self.assert_400(response) def test_get_widget_config(self): """Test case for get_widget_config Get a single widget_config """ - headers = { - "Accept": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } - response = self.client.open(f"/api/widget-config/{MOCK_ID}", method="GET", headers=headers) - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) - assert response.json == MOCK_WIDGET_CONFIG_DICT + # mock in the full config for an existing widget_config + self.mock_widget_config_returns() + + response = self.client.open( + f"/api/widget-config/{MOCK_ID}", method="GET", headers=self.headers_no_content + ) + self.assert_200(response) + assert response.json == MOCK_COMPLETE_WIDGET_CONFIG.to_dict() def test_get_widget_config_404(self): """Test case for get_widget_config Return a 404 when no widget config is found """ - self.mock_widget_config.query.get.return_value = None - headers = { - "Accept": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } - response = self.client.open(f"/api/widget-config/{MOCK_ID}", method="GET", headers=headers) - self.assert_404(response, "Response body is : " + response.data.decode("utf-8")) + # mock in the full config for an existing widget_config + self.mock_widget_config_returns() + self.mock_widget_config.query.get.return_value = None # override query to trigger 404 + + response = self.client.open( + f"/api/widget-config/{MOCK_ID}", method="GET", headers=self.headers_no_content + ) + self.assert_404(response) def test_get_widget_config_list(self): """Test case for get_widget_config_list @@ -130,15 +251,14 @@ def test_get_widget_config_list(self): mock_query = self.mock_widget_config.query mock_query.order_by.return_value.offset.return_value.limit.return_value.all = mock_all mock_query.count.return_value = 1 - headers = { - "Accept": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } - response = self.client.open("/api/widget-config", method="GET", headers=headers) - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) + + response = self.client.open( + "/api/widget-config", method="GET", headers=self.headers_no_content + ) + self.assert_200(response) expected_response = { "pagination": {"page": 1, "pageSize": 25, "totalItems": 1, "totalPages": 1}, - "widgets": [MOCK_WIDGET_CONFIG_DICT], + "widgets": [MOCK_WIDGET_CONFIG.to_dict()], } assert response.json == expected_response @@ -146,52 +266,80 @@ def test_update_widget_config(self): """Test case for update_widget_config Updates a single widget_config + TODO: expand for testing project/portal fetch. same pattern as the add_widget_config controller + TODO: cover navigable + type logic in the update controller """ - widget_config = { + widget_config_update = { "params": { "jenkins_job_name": "stage", }, } - headers = { - "Accept": "application/json", - "Content-Type": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } + # mock in the full config for an existing widget_config + self.mock_widget_config_returns() + + # try to update params, weight should also automatically move from 0 to 10 response = self.client.open( f"/api/widget-config/{MOCK_ID}", method="PUT", - headers=headers, - data=json.dumps(widget_config), + headers=self.headers, + data=json.dumps(widget_config_update), content_type="application/json", ) - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) - assert response.json == UPDATED_WIDGET_CONFIG.to_dict() + self.assert_200(response) + assert response.json == MOCK_UPDATED_WIDGET_CONFIG.to_dict() + + # TODO: test 403 on update + + def test_update_widget_invalid_config_bad_request(self): + """Test case for updating invalid widget config to produce BAD REQUEST + + Should not update the target WC + """ + + # mock in the full config for an existing widget_config + self.mock_widget_config_returns() + + for config in INVALID_WIDGET_CONFIG_COMBOS: + with self.subTest(config=config): + # reset widget config on each subtest + widget_config_dict = MOCK_WIDGET_CONFIG.to_dict() + widget_config_dict.update(config) + # reset mocks on subtest + self.mock_widget_config_returns() + self.mock_widget_config.query.get.return_value = None + + response = self.client.open( + "/api/widget-config/{MOCK_ID}", + method="PUT", + headers=self.headers, + data=json.dumps(widget_config_dict), + content_type="application/json", + ) + + self.assert_400(response) def test_delete_widget_config(self): """Test the deletion of widget configs""" - headers = { - "Accept": "application/json", - "Content-Type": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } + + # mock in the full config for an existing widget_config + self.mock_widget_config_returns() + response = self.client.open( f"/api/widget-config/{MOCK_ID}", method="DELETE", - headers=headers, + headers=self.headers, ) - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_200(response) + + # TODO: test 403 on delete def test_delete_widget_config_404(self): """Test that trying to delete a non-existant widget_config throws a 404""" self.mock_widget_config.query.get.return_value = None - headers = { - "Accept": "application/json", - "Content-Type": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } + response = self.client.open( "/api/widget-config/{id}".format(id="885021da-4a73-4110-9024-eff12b66ce19"), method="DELETE", - headers=headers, + headers=self.headers, ) - self.assert_404(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_404(response) diff --git a/backend/ibutsu_server/test/test_widget_controller.py b/backend/ibutsu_server/test/test_widget_controller.py index cb557a64..6dff6d95 100644 --- a/backend/ibutsu_server/test/test_widget_controller.py +++ b/backend/ibutsu_server/test/test_widget_controller.py @@ -70,17 +70,14 @@ def test_get_comparison_result_list(self, mocked_query): query_string = { "filters": ["metadata.component=frontend", "metadata.component=frontend"], } - headers = { - "Accept": "application/json", - "Authorization": f"Bearer {self.jwt_token}", - } + response = self.client.open( "/api/widget/compare-runs-view", method="GET", - headers=headers, + headers=self.headers_no_content, query_string=query_string, ) - self.assert_200(response, "Response body is : " + response.data.decode("utf-8")) + self.assert_200(response) expected_response = { "pagination": {"totalItems": 1}, "results": [MOCK_RESULTS_DICT], diff --git a/backend/ibutsu_server/util/portals.py b/backend/ibutsu_server/util/portals.py new file mode 100644 index 00000000..89c24f0b --- /dev/null +++ b/backend/ibutsu_server/util/portals.py @@ -0,0 +1,17 @@ +from ibutsu_server.db.models import Portal +from ibutsu_server.util.uuid import is_uuid + + +def get_portal(portal_name): + """Perform a lookup to return the actual portal record""" + if is_uuid(portal_name): + portal = Portal.query.get(portal_name) + else: + portal = Portal.query.filter(Portal.name == portal_name).first() + return portal + + +def get_portal_id(portal_name): + """Shorthand function for a repeated piece of code""" + portal = get_portal(portal_name) + return str(portal.id) if portal else None From 401715708c2dc85f7c67ac712755a7066536a88c Mon Sep 17 00:00:00 2001 From: mshriver Date: Mon, 5 Aug 2024 07:19:34 -0400 Subject: [PATCH 4/4] Update pre-commit --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9a4f2b5d..a6b4ec02 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.5.5 + rev: v0.5.6 hooks: - id: ruff args: