From adacfe895ecbf0965b27ddefe3abb406f1c95e39 Mon Sep 17 00:00:00 2001 From: "Francois G." Date: Mon, 25 Nov 2024 08:38:30 +0100 Subject: [PATCH] Added view for test cases --- README.md | 4 +- src/App.tsx | 2 + src/models/index.ts | 3 + src/models/testingCases.ts | 111 +++++++++++ .../failureEvolution/getFailure.graphql | 25 +++ .../explore/failureEvolution/index.tsx | 175 ++++++++++++++++++ .../testingCases/content/explore/index.tsx | 96 ++++++++++ .../explore/perWeek/getPerWeek.graphql | 24 +++ .../explore/perWeek/historyLineDual.tsx | 112 +++++++++++ .../content/explore/perWeek/index.tsx | 105 +++++++++++ .../quickNumbers/getQuickNumbers.graphql | 10 + .../content/explore/quickNumbers/index.tsx | 94 ++++++++++ .../explore/quickNumbers/numberCard.tsx | 56 ++++++ src/views/testingCases/content/index.tsx | 36 ++++ .../testingCases/content/list/getList.graphql | 22 +++ src/views/testingCases/content/list/index.tsx | 112 +++++++++++ .../facets/getAggregationData.graphql | 14 ++ .../facets/getMetricsFacetData.graphql | 13 ++ src/views/testingCases/facets/index.tsx | 53 ++++++ src/views/testingCases/getConfig.graphql | 31 ++++ src/views/testingCases/index.tsx | 100 ++++++++++ .../testingCases/navTabs/contentTabs.tsx | 34 ++++ src/views/testingCases/navTabs/index.tsx | 15 ++ .../testingCases/navTabs/urlListener.tsx | 26 +++ src/views/testingCases/query/index.tsx | 52 ++++++ 25 files changed, 1323 insertions(+), 2 deletions(-) create mode 100644 src/models/testingCases.ts create mode 100644 src/views/testingCases/content/explore/failureEvolution/getFailure.graphql create mode 100644 src/views/testingCases/content/explore/failureEvolution/index.tsx create mode 100644 src/views/testingCases/content/explore/index.tsx create mode 100644 src/views/testingCases/content/explore/perWeek/getPerWeek.graphql create mode 100644 src/views/testingCases/content/explore/perWeek/historyLineDual.tsx create mode 100644 src/views/testingCases/content/explore/perWeek/index.tsx create mode 100644 src/views/testingCases/content/explore/quickNumbers/getQuickNumbers.graphql create mode 100644 src/views/testingCases/content/explore/quickNumbers/index.tsx create mode 100644 src/views/testingCases/content/explore/quickNumbers/numberCard.tsx create mode 100644 src/views/testingCases/content/index.tsx create mode 100644 src/views/testingCases/content/list/getList.graphql create mode 100644 src/views/testingCases/content/list/index.tsx create mode 100644 src/views/testingCases/facets/getAggregationData.graphql create mode 100644 src/views/testingCases/facets/getMetricsFacetData.graphql create mode 100644 src/views/testingCases/facets/index.tsx create mode 100644 src/views/testingCases/getConfig.graphql create mode 100644 src/views/testingCases/index.tsx create mode 100644 src/views/testingCases/navTabs/contentTabs.tsx create mode 100644 src/views/testingCases/navTabs/index.tsx create mode 100644 src/views/testingCases/navTabs/urlListener.tsx create mode 100644 src/views/testingCases/query/index.tsx diff --git a/README.md b/README.md index 0da2b73..9081e6e 100644 --- a/README.md +++ b/README.md @@ -186,8 +186,8 @@ cd zqueue; yarn run start:dev Without Keycloak: cd zapi; KEYCLOAK_DISABLED=true yarn run start:dev -cd zui; KEYCLOAK_DISABLED=true yarn run start:dev -cd zqueue; yarn run start:dev +cd zui; NODE_OPTIONS=--openssl-legacy-provider KEYCLOAK_DISABLED=true yarn run start:dev +cd zqueue; NODE_OPTIONS=--openssl-legacy-provider yarn run start:dev ``` diff --git a/src/App.tsx b/src/App.tsx index e6db6d0..343626f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,7 @@ import CircleciPipelines from './views/circleciPipelines'; import CircleciInsights from './views/circleciInsights'; import TestingStates from './views/testingStates'; import TestingRuns from './views/testingRuns'; +import TestingCases from './views/testingCases'; import TestingPerfs from './views/testingPerfs'; import BambooRuns from './views/bambooRuns'; import Login from './views/login'; @@ -64,6 +65,7 @@ const App: React.FC = (props: connectedProps) => { } /> } /> } /> + } /> } /> } /> diff --git a/src/models/index.ts b/src/models/index.ts index abda871..7cbdcf1 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -17,6 +17,7 @@ import { circleciPipelines } from './circleciPipelines'; import { circleciInsights } from './circleciInsights'; import { testingStates } from './testingStates'; import { testingRuns } from './testingRuns'; +import { testingCases } from './testingCases'; import { testingPerfs } from './testingPerfs'; import { bambooRuns } from './bambooRuns'; @@ -40,6 +41,7 @@ export interface RootModel { circleciInsights: typeof circleciInsights; testingStates: typeof testingStates; testingRuns: typeof testingRuns; + testingCases: typeof testingCases; testingPerfs: typeof testingPerfs; bambooRuns: typeof bambooRuns; } @@ -64,6 +66,7 @@ export const models: RootModel = { circleciInsights, testingStates, testingRuns, + testingCases, testingPerfs, bambooRuns, }; diff --git a/src/models/testingCases.ts b/src/models/testingCases.ts new file mode 100644 index 0000000..d7d9280 --- /dev/null +++ b/src/models/testingCases.ts @@ -0,0 +1,111 @@ +// https://github.com/pimterry/loglevel +import * as log from 'loglevel'; + +import { Dispatch } from '../store'; + +declare global { + interface Window { + _env_: any; + } +} + +interface TestingStates { + state: any; + reducers: any; + effects: any; +} + +export const testingCases: TestingStates = { + state: { + log: {}, + loading: false, + selectedTab: 'explore', + dataset: 'testingCases', + + query: {}, + queries: [], + + tablePaginationRowsPerPage: 25, + tablePaginationCurrentPage: 0, + tablePaginationOffset: 0, + tablePaginationLimit: 25, + defaultPoints: false, + }, + reducers: { + setLog(state: any, payload: any) { + return { ...state, log: payload }; + }, + setLoading(state: any, payload: any) { + return { ...state, loading: payload }; + }, + setSelectedTab(state: any, payload: any) { + return { ...state, selectedTab: payload }; + }, + setTablePaginationRowsPerPage(state: any, payload: any) { + // Whenever we change the number of rows per page, we also reset all to default + return { + ...state, + tablePaginationRowsPerPage: payload, + tablePaginationLimit: payload, + tablePaginationOffset: 0, + tablePaginationCurrentPage: 0, + }; + }, + setTablePaginationCurrentPage(state: any, newPageNb: number) { + // const updatedOffset = (newPageNb + 1) * state.tablePaginationLimit; + const updatedOffset = newPageNb * state.tablePaginationLimit; + return { ...state, tablePaginationCurrentPage: newPageNb, tablePaginationOffset: updatedOffset }; + }, + setTablePaginationOffset(state: any, payload: any) { + return { ...state, tablePaginationOffset: payload }; + }, + setTablePaginationLimit(state: any, payload: any) { + return { ...state, tablePaginationLimit: payload, tablePaginationCurrentPage: 0, tablePaginationOffset: 0 }; + }, + setQuery(state: any, payload: any) { + return { ...state, query: payload }; + }, + setQueries(state: any, payload: any) { + return { ...state, queries: payload }; + }, + }, + effects: (dispatch: Dispatch) => ({ + async initView() { + const logger = log.noConflict(); + if (process.env.NODE_ENV !== 'production') { + logger.enableAll(); + } else { + logger.disableAll(); + } + logger.info('testingCases Logger initialized'); + dispatch.testingCases.setLog(logger); + }, + + async saveQuery() { + console.log('MODEL SAVE QUERY'); + }, + async deleteQuery() { + console.log('MODEL DELETE QUERY'); + }, + + async updateQueryIfDifferent(newQuery: any, rootState: any) { + const originalQuery = rootState.testingCases.query; + // Only update the store if the query is different than store + // Might need to replace stringify by Lodash isEqual + if (JSON.stringify(originalQuery) !== JSON.stringify(newQuery)) { + if (newQuery === null) { + dispatch.testingCases.setQuery({}); + } else { + dispatch.testingCases.setQuery(newQuery); + } + } + }, + + async updateTabIfDifferent(newTab: any, rootState: any) { + const originalTab = rootState.testingCases.selectedTab; + if (originalTab !== newTab) { + dispatch.testingCases.setSelectedTab(newTab); + } + }, + }), +}; diff --git a/src/views/testingCases/content/explore/failureEvolution/getFailure.graphql b/src/views/testingCases/content/explore/failureEvolution/getFailure.graphql new file mode 100644 index 0000000..f9ffd05 --- /dev/null +++ b/src/views/testingCases/content/explore/failureEvolution/getFailure.graphql @@ -0,0 +1,25 @@ +query($query: String, $interval: String) { + testingCases { + data(query: $query) { + failurerate(interval: $interval, buckets: 50) { + field + fromDateStart + toDateStart + buckets { + key + docCount + caseFailureRate + caseTotal + caseTotalAvg + buckets { + dateStart + docCount + caseFailureRate + caseTotal + caseTotalAvg + } + } + } + } + } +} diff --git a/src/views/testingCases/content/explore/failureEvolution/index.tsx b/src/views/testingCases/content/explore/failureEvolution/index.tsx new file mode 100644 index 0000000..acab12d --- /dev/null +++ b/src/views/testingCases/content/explore/failureEvolution/index.tsx @@ -0,0 +1,175 @@ +import React from 'react'; +import { loader } from 'graphql.macro'; +import { useQuery } from '@apollo/client'; +import add from 'date-fns/add'; +import Typography from '@material-ui/core/Typography'; +import Grid from '@material-ui/core/Grid'; +import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'; +import AddBoxIcon from '@material-ui/icons/AddBox'; +import IndeterminateCheckBoxIcon from '@material-ui/icons/IndeterminateCheckBox'; +import { startOfWeek, startOfMonth } from 'date-fns'; + +import CustomCard from '../../../../../components/customCard'; +import FailureChart from '../../../../../components/charts/nivo/failureChart'; + +const GQL_QUERY = loader('./getFailure.graphql'); + +interface Props { + query: any; + timeWindowPrior: string; + interval: string; + headerTitle: string; +} + +const buildQuery = (sourceQuery: any, additionalData: any) => { + let updatedQuery: any = {}; + + if (Object.keys(sourceQuery).length === 0) { + updatedQuery = { + op: 'and', + content: [], + }; + } else { + updatedQuery = { ...sourceQuery }; + } + + return { + ...updatedQuery, + content: [...updatedQuery.content, ...additionalData], + }; +}; + +const getEmptyWeekCalendar = (firstWeek: Date, lastWeek: Date) => { + const weekCalendar: Array = []; + const currentDate = firstWeek; + lastWeek = add(lastWeek, { days: 1 }); + while (currentDate <= lastWeek) { + const currentWeekU = startOfWeek(currentDate, { weekStartsOn: 1 }); + currentWeekU.setUTCHours(0, 0, 0, 0); // Needed not to take local browser timezone in consideration. + const currentWeek = currentWeekU.toISOString(); + if (!weekCalendar.includes(currentWeek)) { + weekCalendar.push(currentWeek); + } + currentDate.setDate(currentDate.getDate() + 1); + } + return weekCalendar; +}; + +const getEmptyDayCalendar = (firstDay: Date, lastDay: Date) => { + const dayCalendar: Array = []; + const currentDate = firstDay; + lastDay = add(lastDay, { days: 1 }); + while (currentDate <= lastDay) { + currentDate.setUTCHours(0, 0, 0, 0); // Needed not to take local browser timezone in consideration. + dayCalendar.push(currentDate.toISOString()); + currentDate.setDate(currentDate.getDate() + 1); + } + return dayCalendar; +}; + +const getEmptyMonthCalendar = (firstMonth: Date, lastMonth: Date) => { + const weekCalendar: Array = []; + const currentDate = firstMonth; + lastMonth = add(lastMonth, { days: 1 }); + while (currentDate <= lastMonth) { + const currentMonthU = startOfMonth(currentDate); + currentMonthU.setUTCHours(0, 0, 0, 0); // Needed not to take local browser timezone in consideration. + const currentMonth = currentMonthU.toISOString(); + if (!weekCalendar.includes(currentMonth)) { + weekCalendar.push(currentMonth); + } + currentDate.setDate(currentDate.getDate() + 1); + } + return weekCalendar; +}; + +const buildDataset = (data: any, emptyCalendar: Array) => { + const dataset = []; + for (const bucket of data.buckets.filter((b: any) => b.caseTotal > 0)) { + const formattedBucket: any = {}; + formattedBucket['serie'] = bucket.key; + formattedBucket['avg'] = bucket.caseFailureRate; + for (const week of emptyCalendar) { + const dateExists = bucket.buckets.find((w: any) => w.dateStart === week); + if (week !== 'avg') { + if (dateExists === undefined || dateExists.docCount === 0) { + formattedBucket[week] = -1; + } else { + formattedBucket[week] = dateExists.caseFailureRate; + } + } + } + dataset.push(formattedBucket); + } + return dataset; +}; + +const FailureEvolution: React.FC = (props: Props) => { + const { query, timeWindowPrior, headerTitle, interval } = props; + + const timeWindow = [{ op: '>=', content: { field: 'createdAt', value: timeWindowPrior } }]; + + const { data } = useQuery(GQL_QUERY, { + variables: { + query: JSON.stringify(buildQuery(query, timeWindow)), + interval: interval, + }, + fetchPolicy: 'network-only', + }); + if (data !== undefined) { + const dataset = data.testingCases.data.failurerate; + let emptyCalendar: Array = getEmptyWeekCalendar( + new Date(dataset.fromDateStart), + new Date(dataset.toDateStart), + ); + if (interval === 'day') { + emptyCalendar = getEmptyDayCalendar(new Date(dataset.fromDateStart), new Date(dataset.toDateStart)); + } else if (interval === 'month') { + emptyCalendar = getEmptyMonthCalendar(new Date(dataset.fromDateStart), new Date(dataset.toDateStart)); + } + + emptyCalendar.unshift('avg'); + + const updatedDataset = buildDataset(dataset, emptyCalendar); + return ( + + + + + + From period average: + + + + + + Less than 2% variance + + + + + + Degradation (> 2%) + + + + + + Improvement (> 2%) + + + + + + + Displays the top 50 test cases with most failures, use facets on the left to filter down. + + + + + ); + } + return Loading data; +}; + +export default FailureEvolution; diff --git a/src/views/testingCases/content/explore/index.tsx b/src/views/testingCases/content/explore/index.tsx new file mode 100644 index 0000000..a2e8ca3 --- /dev/null +++ b/src/views/testingCases/content/explore/index.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; + +import { makeStyles } from '@material-ui/core/styles'; +import Grid from '@material-ui/core/Grid'; + +import PerWeek from './perWeek'; +import FailureEvolution from './failureEvolution'; +import QuickNumbers from './quickNumbers'; + +import { iRootState } from '../../../../store'; + +import { sub, add } from 'date-fns'; + +import { createTermFilter, addFilterToQuery } from '../../../../utils/query'; + +const mapState = (state: iRootState) => ({ + query: state.testingCases.query, +}); + +const mapDispatch = () => ({}); + +const useStyles = makeStyles({ + root: { + marginTop: 10, + }, +}); + +const buildFilterWeek = (weekStart: string, weekEnd: string, query: any) => { + let updatedQuery: any = {}; + + const filterFrom = createTermFilter('>=', 'createdAt', weekStart); + updatedQuery = addFilterToQuery(filterFrom, query); + + const filterTo = createTermFilter('<=', 'createdAt', weekEnd); + updatedQuery = addFilterToQuery(filterTo, updatedQuery); + + return updatedQuery; +}; + +type connectedProps = ReturnType & ReturnType & RouteComponentProps; +const Explore: React.FC = (props: connectedProps) => { + const { query, history } = props; + const classes = useStyles(); + + const openQuery = (newQuery: any) => { + history.push({ + pathname: '/testingCases', + search: '?q=' + encodeURIComponent(JSON.stringify(newQuery)) + '&tab=' + encodeURIComponent('list'), + state: { detail: newQuery }, + }); + }; + + const openWeek = (weekData: any) => { + const weekStart = weekData.state; + const weekEnd = add(new Date(weekStart), { days: 7 }).toISOString(); + const updatedQuery = buildFilterWeek(weekStart, weekEnd, query); + openQuery(updatedQuery); + }; + + /* + For some unknown reason, passing a date function, as a variable of the apollo query + Results in the query repeating over and over, therefore, moving this one level up + */ + const thirtyDaysPrior = sub(new Date(), { days: 30 }).toISOString(); + + return ( + + + + + + + + + + + + + + + ); +}; + +export default withRouter(connect(mapState, mapDispatch)(Explore)); diff --git a/src/views/testingCases/content/explore/perWeek/getPerWeek.graphql b/src/views/testingCases/content/explore/perWeek/getPerWeek.graphql new file mode 100644 index 0000000..6cf99d4 --- /dev/null +++ b/src/views/testingCases/content/explore/perWeek/getPerWeek.graphql @@ -0,0 +1,24 @@ +query($query: String, $aggOptionsAllCases: String, $aggOptionsFailedCases: String) { + testingCases { + data(query: $query) { + allCases: aggregations(field: "createdAt", aggType: "date_histogram", aggOptions: $aggOptionsAllCases) { + buckets { + keyAsString + key + docCount + moving + sum + } + } + failedCases: aggregations(field: "createdAt", aggType: "date_histogram", aggOptions: $aggOptionsFailedCases) { + buckets { + keyAsString + key + docCount + moving + sum + } + } + } + } +} diff --git a/src/views/testingCases/content/explore/perWeek/historyLineDual.tsx b/src/views/testingCases/content/explore/perWeek/historyLineDual.tsx new file mode 100644 index 0000000..7fdf644 --- /dev/null +++ b/src/views/testingCases/content/explore/perWeek/historyLineDual.tsx @@ -0,0 +1,112 @@ +import React, { Component } from 'react'; // let's also import Component +import { createStyles, withStyles } from '@material-ui/core/styles'; +import Chart from 'chart.js'; + +const styles = () => + createStyles({ + root: { + minWidth: '400', + }, + }); + +class HistoryLineDual extends Component { + chartRef: any = React.createRef(); + chart: any = {}; + allowClick = true; + + componentDidMount() { + this.buildChart(); + } + + componentDidUpdate() { + this.buildChart(); + } + + resetAllowClick = () => { + this.allowClick = true; + }; + + clickChart = (event: any) => { + const { dataset, openClick } = this.props; + const activePoints = this.chart.getElementsAtEvent(event); + if (activePoints[0] !== undefined) { + const idx = activePoints[0]._index; + if (this.allowClick === true) { + this.allowClick = false; + const clickedBucket = dataset[idx]; + setTimeout(() => { + openClick(clickedBucket); + this.resetAllowClick(); + }, 500); + } + } + }; + + buildChart = () => { + const { chartData } = this.props; + const myChartRef = this.chartRef.current.getContext('2d'); + + if (this.chart.destroy !== undefined) { + this.chart.destroy(); + } + this.chart = new Chart(myChartRef, { + type: 'bar', + data: chartData, + options: { + // responsive: false, + onClick: this.clickChart, + maintainAspectRatio: false, + scales: { + yAxes: [ + { + id: 'yleft', + position: 'left', + ticks: { + beginAtZero: true, + }, + scaleLabel: { + display: true, + labelString: 'Tests Count', + }, + }, + { + id: 'yright', + position: 'right', + ticks: { + beginAtZero: true, + }, + gridLines: { + drawOnChartArea: false, // only want the grid lines for one axis to show up + }, + scaleLabel: { + display: true, + labelString: 'Failure rate (%)', + }, + }, + ], + }, + tooltips: { + position: 'nearest', + mode: 'index', + intersect: false, + }, + plugins: { + datalabels: { + display: false, + }, + }, + }, + }); + }; + + render() { + const { classes } = this.props; + return ( +
+ +
+ ); + } +} + +export default withStyles(styles)(HistoryLineDual); diff --git a/src/views/testingCases/content/explore/perWeek/index.tsx b/src/views/testingCases/content/explore/perWeek/index.tsx new file mode 100644 index 0000000..4095236 --- /dev/null +++ b/src/views/testingCases/content/explore/perWeek/index.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { loader } from 'graphql.macro'; +import { useQuery } from '@apollo/client'; + +import CustomCard from '../../../../../components/customCard'; + +import format from 'date-fns/format'; +import parseISO from 'date-fns/parseISO'; + +import HistoryLineDual from './historyLineDual'; + +const GQL_QUERY = loader('./getPerWeek.graphql'); + +interface Props { + query: any; + openWeek: (week: any) => void; +} + +const PerWeek: React.FC = (props: Props) => { + const { query, openWeek } = props; + + const { data } = useQuery(GQL_QUERY, { + variables: { + query: JSON.stringify(query), + aggOptionsAllCases: JSON.stringify({ calendarInterval: 'week', sumField: 'caseSuccess' }), + aggOptionsFailedCases: JSON.stringify({ calendarInterval: 'week', sumField: 'caseFailure' }), + }, + fetchPolicy: 'network-only', + }); + if (data !== undefined) { + // Normalize the dataset (x points might not be the same) + const datapoints: Array = []; + for (const item of data.testingCases.data.allCases.buckets) { + if (!datapoints.includes(item.keyAsString)) { + datapoints.push(item.keyAsString); + } + } + + const dataseriesSuccess: Array = []; + const dataseriesFailure: Array = []; + const dataseriesTotal: Array = []; + const dataseriesPrctFailure: Array = []; + // Prep the data + for (const x of datapoints) { + const createdAtSuccess = data.testingCases.data.allCases.buckets.find((item: any) => item.keyAsString === x); + dataseriesSuccess.push({ + state: x, + createdAt: createdAtSuccess !== undefined ? createdAtSuccess.sum : 0, + }); + + const createdAtFailed = data.testingCases.data.failedCases.buckets.find((item: any) => item.keyAsString === x); + dataseriesFailure.push({ + state: x, + createdAt: createdAtFailed !== undefined ? createdAtFailed.sum : 0, + }); + + dataseriesTotal.push({ + state: x, + createdAt: createdAtFailed.sum + createdAtSuccess.sum, + }); + + dataseriesPrctFailure.push({ + state: x, + createdAt: Math.round((createdAtFailed.sum * 100) / (createdAtFailed.sum + createdAtSuccess.sum)), + }); + } + + const chartData = { + datasets: [ + { + label: 'Failure rate (%)', + data: dataseriesPrctFailure.map((item: any) => item.createdAt), + backgroundColor: 'rgb(255, 99, 132)', + borderColor: 'rgb(255, 99, 132)', + type: 'line', + pointRadius: 0, + pointHitRadius: 5, + fill: false, + yAxisID: 'yright', + }, + { + label: 'Executed tests', + data: dataseriesTotal.map((item: any) => item.createdAt), + backgroundColor: 'rgb(54, 162, 235)', + borderColor: 'rgb(54, 162, 235)', + pointRadius: 0, + pointHitRadius: 5, + type: 'bar', + fill: false, + yAxisID: 'yleft', + }, + ], + labels: dataseriesSuccess.map((item: any) => format(parseISO(item.state), 'LLL do yyyy')), + }; + + return ( + + + + ); + } + return Loading data; +}; + +export default PerWeek; diff --git a/src/views/testingCases/content/explore/quickNumbers/getQuickNumbers.graphql b/src/views/testingCases/content/explore/quickNumbers/getQuickNumbers.graphql new file mode 100644 index 0000000..00e2f2b --- /dev/null +++ b/src/views/testingCases/content/explore/quickNumbers/getQuickNumbers.graphql @@ -0,0 +1,10 @@ +query($currentQuery: String, $thirtyDays: String) { + testingCases { + currentQuery: data(query: $currentQuery) { + count + } + thirtyDays: data(query: $thirtyDays) { + count + } + } +} diff --git a/src/views/testingCases/content/explore/quickNumbers/index.tsx b/src/views/testingCases/content/explore/quickNumbers/index.tsx new file mode 100644 index 0000000..cac647f --- /dev/null +++ b/src/views/testingCases/content/explore/quickNumbers/index.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { loader } from 'graphql.macro'; +import { useQuery } from '@apollo/client'; + +import Grid from '@material-ui/core/Grid'; +import { makeStyles } from '@material-ui/core/styles'; + +import NumberCard from './numberCard'; + +const QUICKNUMBERS_QUERY = loader('./getQuickNumbers.graphql'); + +interface Props { + query: any; + thirtyDaysPrior: string; + openQuery: (query: any) => void; +} + +const useStyles = makeStyles({ + root: { + marginLeft: 0, + marginRight: 20, + }, + title: { + fontSize: 14, + }, + pos: { + marginBottom: 12, + }, +}); + +const buildQuery = (sourceQuery: any, additionalData: any) => { + let updatedQuery: any = {}; + + if (Object.keys(sourceQuery).length === 0) { + updatedQuery = { + op: 'and', + content: [], + }; + } else { + updatedQuery = { ...sourceQuery }; + } + + return { + ...updatedQuery, + content: [...updatedQuery.content, ...additionalData], + }; +}; + +const QuickNumbers: React.FC = (props: Props) => { + const { query, thirtyDaysPrior, openQuery } = props; + const classes = useStyles(); + + const thirtyDays = [{ op: '>=', content: { field: 'createdAt', value: thirtyDaysPrior } }]; + + const { data } = useQuery(QUICKNUMBERS_QUERY, { + variables: { + currentQuery: JSON.stringify(query), + thirtyDays: JSON.stringify(buildQuery(query, thirtyDays)), + }, + fetchPolicy: 'network-only', + }); + + if (data === undefined) { + return null; + } + const cards = [ + { + key: 1, + count: data.testingCases.currentQuery.count, + query: query, + title: 'Cases in current query', + }, + { + key: 2, + count: data.testingCases.thirtyDays.count, + query: buildQuery(query, thirtyDays), + title: 'Cases in the last 30 days', + }, + ]; + + return ( + + {cards.map((card: any) => { + return ( + + + + ); + })} + + ); +}; + +export default QuickNumbers; diff --git a/src/views/testingCases/content/explore/quickNumbers/numberCard.tsx b/src/views/testingCases/content/explore/quickNumbers/numberCard.tsx new file mode 100644 index 0000000..10c515c --- /dev/null +++ b/src/views/testingCases/content/explore/quickNumbers/numberCard.tsx @@ -0,0 +1,56 @@ +import React from 'react'; + +import { makeStyles } from '@material-ui/core/styles'; +import Card from '@material-ui/core/Card'; +import CardContent from '@material-ui/core/CardContent'; +import Typography from '@material-ui/core/Typography'; + +import IconButton from '@material-ui/core/IconButton'; +import SearchIcon from '@material-ui/icons/Search'; + +interface Props { + count: number; + title: string; + query: any; + openQuery: (query: any) => void; +} + +const useStyles = makeStyles({ + root: { + height: 150, + // maxWidth: 200, + }, + title: { + fontSize: 14, + }, + pos: { + marginBottom: 12, + }, +}); + +const QuickNumbers: React.FC = (props: Props) => { + const { count, title, query, openQuery } = props; + const classes = useStyles(); + + const onClick = () => { + openQuery(query); + }; + + return ( + + + + {count} + + + + + + {title} + + + + ); +}; + +export default QuickNumbers; diff --git a/src/views/testingCases/content/index.tsx b/src/views/testingCases/content/index.tsx new file mode 100644 index 0000000..2fb17b1 --- /dev/null +++ b/src/views/testingCases/content/index.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +import { iRootState } from '../../../store'; +import { TableConfig } from '../../../global'; + +import Explore from './explore'; +import List from './list'; + +const mapState = (state: iRootState) => ({ + selectedTab: state.testingCases.selectedTab, +}); + +const mapDispatch = (dispatch: any) => ({ + setSelectedTab: dispatch.testingCases.setSelectedTab, +}); + +interface Props { + tableConfig: TableConfig; +} + +type connectedProps = ReturnType & ReturnType & Props; + +const Content: React.FC = (props: connectedProps) => { + const { selectedTab, tableConfig } = props; + switch (selectedTab) { + case 'explore': + return ; + case 'list': + return ; + default: + return null; + } +}; + +export default connect(mapState, mapDispatch)(Content); diff --git a/src/views/testingCases/content/list/getList.graphql b/src/views/testingCases/content/list/getList.graphql new file mode 100644 index 0000000..7236d6a --- /dev/null +++ b/src/views/testingCases/content/list/getList.graphql @@ -0,0 +1,22 @@ +query($from: Int, $size: Int, $query: String, $sortField: String, $sortDirection: OrderDirection!) { + dataset: testingCases { + data(query: $query) { + count + items(from: $from, size: $size, orderBy: { direction: $sortDirection, field: $sortField }) { + totalCount + nodes { + id + createdAt + name + suite + jahia + module + full + state + url + duration + } + } + } + } +} diff --git a/src/views/testingCases/content/list/index.tsx b/src/views/testingCases/content/list/index.tsx new file mode 100644 index 0000000..a435c6e --- /dev/null +++ b/src/views/testingCases/content/list/index.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { loader } from 'graphql.macro'; + +import { useQuery } from '@apollo/client'; + +import { iRootState } from '../../../../store'; +import { TableConfig, TableSort, TablePaginationType } from '../../../../global'; + +import SimpleTable from '../../../../components/tables/simple'; +import ExportTsv from '../../../../components/tables/exportTsv'; + +const GQL_QUERY = loader('./getList.graphql'); + +//https://www.apollographql.com/docs/react/data/pagination/ + +const mapState = (state: iRootState) => ({ + query: state.testingCases.query, + tablePaginationRowsPerPage: state.testingCases.tablePaginationRowsPerPage, + tablePaginationCurrentPage: state.testingCases.tablePaginationCurrentPage, + tablePaginationOffset: state.testingCases.tablePaginationOffset, + tablePaginationLimit: state.testingCases.tablePaginationLimit, +}); + +const mapDispatch = (dispatch: any) => ({ + setTablePaginationRowsPerPage: dispatch.testingCases.setTablePaginationRowsPerPage, + setTablePaginationCurrentPage: dispatch.testingCases.setTablePaginationCurrentPage, + setTablePaginationOffset: dispatch.testingCases.setTablePaginationOffset, + setTablePaginationLimit: dispatch.testingCases.setTablePaginationLimit, +}); + +interface Props { + tableConfig: TableConfig; +} + +type connectedProps = ReturnType & ReturnType & Props; + +const List: React.FC = (props: connectedProps) => { + const { + tablePaginationOffset, + tablePaginationLimit, + setTablePaginationLimit, + tablePaginationCurrentPage, + setTablePaginationCurrentPage, + query, + tableConfig, + } = props; + + const [sortField, setSortField] = React.useState(tableConfig.defaultSortField); + const [sortDirection, setSortDirection] = React.useState<'desc' | 'asc'>('desc'); + + const changeRowsPerPage = (event: React.ChangeEvent) => { + setTablePaginationLimit(parseInt(event.target.value, 10)); + }; + + const changeCurrentPage = (event: unknown, newPage: number) => { + setTablePaginationCurrentPage(newPage); + }; + + const { data } = useQuery(GQL_QUERY, { + variables: { + from: tablePaginationOffset, + size: tablePaginationLimit, + query: JSON.stringify(query), + sortField: sortField, + sortDirection: sortDirection, + }, + fetchPolicy: 'network-only', + }); + if (data !== undefined) { + const totalCount = data.dataset.data.count; + const nodes = data.dataset.data.items.nodes; + + const tableSort: TableSort = { + setSortField: setSortField, + sortField: sortField, + sortDirection: sortDirection, + setSortDirection: setSortDirection, + }; + + const tablePagination: TablePaginationType = { + tablePaginationLimit: tablePaginationLimit, + tablePaginationCurrentPage: tablePaginationCurrentPage, + changeCurrentPage: changeCurrentPage, + changeRowsPerPage: changeRowsPerPage, + }; + + return ( + + + } + /> + + ); + } + return null; +}; + +export default connect(mapState, mapDispatch)(List); diff --git a/src/views/testingCases/facets/getAggregationData.graphql b/src/views/testingCases/facets/getAggregationData.graphql new file mode 100644 index 0000000..7cf9e20 --- /dev/null +++ b/src/views/testingCases/facets/getAggregationData.graphql @@ -0,0 +1,14 @@ +query($field: String!, $query: String) { + testingCases { + data(query: $query) { + aggregations(field: $field) { + field + buckets { + docCount + key + count + } + } + } + } +} diff --git a/src/views/testingCases/facets/getMetricsFacetData.graphql b/src/views/testingCases/facets/getMetricsFacetData.graphql new file mode 100644 index 0000000..56ca321 --- /dev/null +++ b/src/views/testingCases/facets/getMetricsFacetData.graphql @@ -0,0 +1,13 @@ +query($field: String!, $query: String) { + testingCases { + data(query: $query) { + metrics(field: $field) { + field + overallMin + overallMax + min + max + } + } + } +} diff --git a/src/views/testingCases/facets/index.tsx b/src/views/testingCases/facets/index.tsx new file mode 100644 index 0000000..a8d00fc --- /dev/null +++ b/src/views/testingCases/facets/index.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { loader } from 'graphql.macro'; + +import { connect } from 'react-redux'; + +import { iRootState } from '../../../store'; + +import Facets from '../../../components/facets'; + +const gqlAggregationData = loader('./getAggregationData.graphql'); +const gqlMetricsFacet = loader('./getMetricsFacetData.graphql'); + +interface Facet { + field: string; + facetType: string; + name: string; + nullValue: string; + nullFilter: string; + default: boolean; +} + +interface Props { + facets: Facet[]; + pushNewQuery: (query: any) => void; +} + +const mapState = (state: iRootState) => ({ + defaultPoints: state.testingCases.defaultPoints, + dataset: state.testingCases.dataset, + query: state.testingCases.query, +}); + +const mapDispatch = () => ({}); + +type connectedProps = Props & ReturnType & ReturnType; + +const FacetsHoc: React.FC = (props: connectedProps) => { + const { facets, defaultPoints, dataset, query, pushNewQuery } = props; + return ( + + ); +}; + +export default connect(mapState, mapDispatch)(FacetsHoc); diff --git a/src/views/testingCases/getConfig.graphql b/src/views/testingCases/getConfig.graphql new file mode 100644 index 0000000..63f2730 --- /dev/null +++ b/src/views/testingCases/getConfig.graphql @@ -0,0 +1,31 @@ +query { + dataset: testingCases { + config { + table { + itemsType + defaultSortField + columns { + name + field + subfield + fieldType + sortField + linkField + sortable + default + } + } + aggregations { + totalCount + nodes { + name + field + facetType + default + nullValue + nullFilter + } + } + } + } +} diff --git a/src/views/testingCases/index.tsx b/src/views/testingCases/index.tsx new file mode 100644 index 0000000..70a0699 --- /dev/null +++ b/src/views/testingCases/index.tsx @@ -0,0 +1,100 @@ +import React, { useEffect } from 'react'; +import { connect } from 'react-redux'; +import { loader } from 'graphql.macro'; +import { useQuery } from '@apollo/client'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; + +import { makeStyles } from '@material-ui/core/styles'; +import Grid from '@material-ui/core/Grid'; +import { TableConfig } from '../../global'; + +import Layout from '../../layout'; +import NavTabs from './navTabs'; + +import Content from './content'; +import FacetsHoc from './facets'; +import Query from './query'; + +const GQL_GETCONFIG = loader('./getConfig.graphql'); + +const mapState = () => ({}); + +const mapDispatch = (dispatch: any) => ({ + updateQueryIfDifferent: dispatch.testingCases.updateQueryIfDifferent, +}); + +const useStyles = makeStyles(() => ({ + fullWidth: { + width: '100%', + }, +})); + +interface Facet { + field: string; + facetType: string; + name: string; + nullValue: string; + nullFilter: string; + default: boolean; +} + +type connectedProps = ReturnType & ReturnType & RouteComponentProps; + +const TestingCases: React.FC = (props: connectedProps) => { + const classes = useStyles(); + const { updateQueryIfDifferent, location, history } = props; + + const pushNewQuery = (modifiedQuery: any) => { + history.push({ + pathname: '/testingCases', + search: '?q=' + encodeURIComponent(JSON.stringify(modifiedQuery)), + state: { detail: modifiedQuery }, + }); + }; + + useEffect(() => { + const params = new URLSearchParams(location.search); + if (params.get('q') !== null) { + const queryRaw = params.get('q'); + if (queryRaw !== null) { + const queryUrl = decodeURIComponent(queryRaw); + updateQueryIfDifferent(JSON.parse(queryUrl)); + } + } + }); + + const { data } = useQuery(GQL_GETCONFIG, { + fetchPolicy: 'network-only', + }); + + if (data === undefined) { + return

Loading..., please wait

; + } else { + const facets: Facet[] = data.dataset.config.aggregations.nodes; + const tableConfig: TableConfig = data.dataset.config.table; + return ( + + + + + + + + + + + + + + + + + + + + + ); + } +}; + +export default withRouter(connect(mapState, mapDispatch)(TestingCases)); diff --git a/src/views/testingCases/navTabs/contentTabs.tsx b/src/views/testingCases/navTabs/contentTabs.tsx new file mode 100644 index 0000000..0459e98 --- /dev/null +++ b/src/views/testingCases/navTabs/contentTabs.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +import Tabs from '@material-ui/core/Tabs'; +import Tab from '@material-ui/core/Tab'; + +import { iRootState } from '../../../store'; + +const mapState = (state: iRootState) => ({ + selectedTab: state.testingCases.selectedTab, +}); + +const mapDispatch = (dispatch: any) => ({ + setSelectedTab: dispatch.testingCases.setSelectedTab, +}); + +type connectedProps = ReturnType & ReturnType; + +const ContentTabs: React.FC = (props: connectedProps) => { + const { selectedTab, setSelectedTab } = props; + + const handleChange = (event: React.ChangeEvent, newValue: number) => { + setSelectedTab(newValue); + }; + + return ( + + + + + ); +}; + +export default connect(mapState, mapDispatch)(ContentTabs); diff --git a/src/views/testingCases/navTabs/index.tsx b/src/views/testingCases/navTabs/index.tsx new file mode 100644 index 0000000..da4335f --- /dev/null +++ b/src/views/testingCases/navTabs/index.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +import UrlListener from './urlListener'; +import ContentTabs from './contentTabs'; + +const NavTabs: React.FC = () => { + return ( + + + + + ); +}; + +export default NavTabs; diff --git a/src/views/testingCases/navTabs/urlListener.tsx b/src/views/testingCases/navTabs/urlListener.tsx new file mode 100644 index 0000000..355f1f9 --- /dev/null +++ b/src/views/testingCases/navTabs/urlListener.tsx @@ -0,0 +1,26 @@ +import React, { useEffect } from 'react'; +import { connect } from 'react-redux'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; + +const mapState = () => ({}); + +const mapDispatch = (dispatch: any) => ({ + updateTabIfDifferent: dispatch.testingCases.updateTabIfDifferent, +}); + +type connectedProps = ReturnType & ReturnType & RouteComponentProps; + +const UrlListener: React.FC = (props: connectedProps) => { + const { updateTabIfDifferent, location } = props; + + useEffect(() => { + const params = new URLSearchParams(location.search); + if (params.get('tab') !== undefined && params.get('tab') !== null) { + updateTabIfDifferent(params.get('tab')); + } + }); + + return null; +}; + +export default withRouter(connect(null, mapDispatch)(UrlListener)); diff --git a/src/views/testingCases/query/index.tsx b/src/views/testingCases/query/index.tsx new file mode 100644 index 0000000..ce801b8 --- /dev/null +++ b/src/views/testingCases/query/index.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +import { iRootState } from '../../../store'; +import Query from '../../../components/query'; + +const mapState = (state: iRootState) => ({ + query: state.testingCases.query, + dataset: state.testingCases.dataset, + queries: state.testingCases.queries, + dexieDb: state.global.dexieDb, +}); + +const mapDispatch = (dispatch: any) => ({ + saveQuery: dispatch.testingCases.saveQuery, + deleteQuery: dispatch.testingCases.deleteQuery, + setSelectedTab: dispatch.testingCases.setSelectedTab, + setQueries: dispatch.testingCases.setQueries, + setQuery: dispatch.testingCases.setQuery, +}); + +interface Props { + facets: Array; +} + +interface Facet { + field: string; + facetType: string; + name: string; + nullValue: string; + nullFilter: string; + default: boolean; +} + +type connectedProps = ReturnType & ReturnType & Props; + +const QueryHoc: React.FC = (props: connectedProps) => { + const { query, facets, dexieDb, dataset, setQueries, queries } = props; + + return ( + + ); +}; + +export default connect(mapState, mapDispatch)(QueryHoc);