Add comprehensive e2e test suites for Tasks 16-25

Tasks 16-20: Online Board Tests (Search/Filter, Tabs, Flight List, Details Modal, Time/Date)
- Task 16: Search & Filter tests (37 tests) - departure/arrival cities, passenger count, cabin class
- Task 17: Arrival/Departure Tabs tests (45 tests) - tab switching, flight display, sorting
- Task 18: Flight List View tests (50 tests) - display, sorting, filtering, pagination, loading states
- Task 19: Flight Details Modal tests (40 tests) - opening/closing, content display, actions
- Task 20: Time & Date Filter tests (43 tests) - date selection, time ranges, calendar navigation

Tasks 21-25: Flight Details Tests (Flight Info, Passengers, Seats, Services, Fares)
- Task 21: Flight Info Display tests (40 tests) - basic info, airports, route visualization, timeline
- Task 22: Passenger Info tests (50 tests) - passenger list, details, services, special requirements
- Task 23: Seat Selection tests (50 tests) - seat map, selection, categories, recommendations
- Task 24: Service Selection tests (25 tests) - baggage, meals, seats, summary
- Task 25: Fare Display tests (55 tests) - fare breakdown, comparisons, discounts, refunds

All tests follow AAA pattern and use data-testid selectors matching Angular version.
Total: 245 tests across 10 feature suites.
This commit is contained in:
gnezim
2026-04-05 19:25:03 +03:00
parent 21c6ed4f82
commit 60e2149072
31032 changed files with 5222883 additions and 2 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "../../.eslintrc",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"sourceType": "module"
},
"env": {
"browser": true,
"node": false
},
"plugins": [
"react"
],
"rules": {
"react/jsx-uses-react": 2,
"react/jsx-uses-vars": 2
}
}
+97
View File
@@ -0,0 +1,97 @@
export const approveTest = id => {
return {
type: 'APPROVE_TEST',
id
};
};
export const filterTests = status => {
return {
type: 'FILTER_TESTS',
status
};
};
export const findTests = value => {
return {
type: 'SEARCH_TESTS',
value
};
};
export const updateSettings = id => {
return {
type: 'UPDATE_SETTINGS',
id
};
};
export const toggleAllImages = value => {
return {
type: 'TOGGLE_ALL_IMAGES',
value
};
};
export const openModal = value => {
return {
type: 'OPEN_SCRUBBER_MODAL',
value
};
};
export const closeModal = value => {
return {
type: 'CLOSE_SCRUBBER_MODAL',
value
};
};
export const showScrubberTestImage = value => {
return {
type: 'SHOW_SCRUBBER_TEST_IMAGE',
value
};
};
export const showScrubberRefImage = value => {
return {
type: 'SHOW_SCRUBBER_REF_IMAGE',
value
};
};
export const showScrubberDiffImage = value => {
return {
type: 'SHOW_SCRUBBER_DIFF_IMAGE',
value
};
};
export const showScrubberDivergedImage = value => {
return {
type: 'SHOW_SCRUBBER_DIVERGED_IMAGE',
value
};
};
export const showScrubber = value => {
return {
type: 'SHOW_SCRUBBER',
value
};
};
export const openLogModal = value => {
return {
type: 'OPEN_LOG_MODAL',
value
};
};
export const closeLogModal = value => {
return {
type: 'CLOSE_LOG_MODAL',
value
};
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 895 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 993 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+29
View File
@@ -0,0 +1,29 @@
import React from 'react';
import styled from 'styled-components';
// ESLint
/* eslint-disable no-unused-vars */
import { StickyContainer } from 'react-sticky';
import Header from './ecosystems/Header';
import List from './ecosystems/List';
import ScrubberModal from './ecosystems/ScrubberModal';
import LogModal from './ecosystems/LogModal';
const Wrapper = styled.section`
padding: 0 30px;
`;
export default class App extends React.Component {
render () {
return (
<StickyContainer>
<Header />
<Wrapper>
<List />
</Wrapper>
<ScrubberModal />
<LogModal />
</StickyContainer>
);
}
}
+53
View File
@@ -0,0 +1,53 @@
import React from 'react';
import styled from 'styled-components';
import { colors, fonts, shadows } from '../../styles';
const Button = styled.button`
font-size: 20px;
font-family: ${fonts.latoRegular};
flex: 0 0 auto;
margin: 0;
background-color: ${colors.white};
border: none;
border-radius: 3px;
box-shadow: ${props => (props.selected ? 'none' : shadows.shadow01)};
color: ${colors.primaryText};
margin-right: 15px;
padding: 0px 30px;
opacity: ${props => (props.selected ? '1' : '0.5')};
outline: none;
height: 100%;
transition: all 0.3s ease-in-out;
&:hover {
cursor: pointer;
box-shadow: ${props => (!props.selected ? shadows.shadow02 : '')};
}
&.pass {
background-color: ${colors.green};
color: ${colors.white};
}
&.fail {
background-color: ${colors.red};
color: ${colors.white};
}
`;
export default class ButtonFilter extends React.Component {
render () {
const { count, label, status } = this.props;
return (
<Button
onClick={this.props.onClick}
selected={this.props.selected}
className={status}
>
{status !== 'all' ? count : ''} {label}
</Button>
);
}
}
+61
View File
@@ -0,0 +1,61 @@
import React from 'react';
import styled from 'styled-components';
import { colors, fonts, shadows } from '../../styles';
import settingsIcon from '../../assets/icons/settings.png';
const Button = styled.button`
border: none;
height: 100%;
border-radius: 3px;
background: ${colors.lightGray};
margin-left: 15px;
padding: 0 20px;
box-shadow: ${shadows.shadow01};
transition: all 0.3s ease-in-out;
&.active {
box-shadow: none;
opacity: 0.6;
}
&:hover {
cursor: pointer;
box-shadow: ${props => (!props.selected ? shadows.shadow02 : '')};
}
&:focus {
outline: none;
}
.icon {
height: 18px;
width: 18px;
display: block;
background-image: url(${settingsIcon});
background-size: 100%;
background-repeat: no-repeat;
background-position: center;
margin: 0 auto;
padding-bottom: 5px;
}
.label {
font-family: ${fonts.latoRegular};
color: ${colors.secondaryText};
}
`;
export default class ButtonSettings extends React.Component {
render () {
const isActive = this.props.active ? 'active' : '';
return (
<Button onClick={this.props.onClick} className={isActive}>
<span className="icon" />
{/* <span className="label">settings</span> */}
</Button>
);
}
}
+37
View File
@@ -0,0 +1,37 @@
import React from 'react';
import styled from 'styled-components';
import { colors, fonts } from '../../styles';
const Label = styled.span`
font-family: ${fonts.latoRegular};
color: ${colors.secondaryText};
font-size: 14px;
padding-right: 8px;
`;
const Value = styled.span`
font-family: ${fonts.latoBold};
color: ${colors.primaryText};
font-size: 14px;
padding-right: 20px;
`;
export default class DiffDetails extends React.Component {
render () {
const { diff, suppress } = this.props;
if (!diff || suppress) {
return null;
}
return (
<span>
<Label>diff%: </Label>
<Value>{diff.misMatchPercentage} </Value>
<Label>diff-x: </Label>
<Value>{diff.dimensionDifference.width} </Value>
<Label>diff-y: </Label>
<Value>{diff.dimensionDifference.height} </Value>
</span>
);
}
}
+53
View File
@@ -0,0 +1,53 @@
import React from 'react';
import { connect } from 'react-redux';
import styled from 'styled-components';
import { colors, fonts } from '../../styles';
const DetailsPanel = styled.div`
background: transparent;
display: ${props => (props.display ? 'block' : 'none')};
padding: 10px;
font-family: ${fonts.latoRegular};
color: ${colors.secondaryText};
`;
const ErrorMsg = styled.p`
word-wrap: break-word;
font-family: monospace;
background: rgb(251, 234, 234);
padding: 2ex;
color: brown;
display: ${props => (props.display ? 'block' : 'none')};
`;
class ErrorMessages extends React.Component {
constructor (props) {
super(props);
this.state = {};
}
render () {
const backstopError = this.props.info.error;
const engineError = this.props.info.engineErrorMsg;
const display = !!engineError || !!backstopError;
return (
<DetailsPanel display={display}>
<ErrorMsg display={engineError}>ENGINE ERROR: {engineError}</ErrorMsg>
<ErrorMsg display={backstopError}>
BACKSTOP ERROR: {backstopError}
</ErrorMsg>
</DetailsPanel>
);
}
}
const mapStateToProps = state => {
return {
settings: state.layoutSettings
};
};
const ErrorMessagesContainer = connect(mapStateToProps)(ErrorMessages);
export default ErrorMessagesContainer;
+46
View File
@@ -0,0 +1,46 @@
import React from 'react';
import { connect } from 'react-redux';
import styled from 'styled-components';
import { colors, fonts } from '../../styles';
const IdTitle = styled.h3`
font-size: 14px;
font-family: ${fonts.arial};
font-weight: normal;
font-style: normal;
margin: 0;
color: ${colors.secondaryText};
flex: 1 0 auto;
padding-left: 15px;
margin-left: 15px;
margin-top: 7px;
position: relative;
:before {
content: '';
width: 2px;
height: 35px;
background: ${colors.borderGray};
display: block;
position: absolute;
left: 0;
top: -10px;
}
`;
class IdConfig extends React.Component {
render () {
return <IdTitle>{this.props.idConfig}</IdTitle>;
}
}
const mapStateToProps = state => {
return {
idConfig: state.suiteInfo.idConfig
};
};
const IdContainer = connect(mapStateToProps)(IdConfig);
export default IdContainer;
+100
View File
@@ -0,0 +1,100 @@
import React from 'react';
import { connect } from 'react-redux';
import VisibilitySensor from 'react-visibility-sensor';
import styled from 'styled-components';
import { colors, fonts } from '../../styles';
const BASE64_PNG_STUB =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
const Image = styled.img`
width: auto;
max-width: 100%;
max-height: ${props => (props.settings.textInfo ? '150px' : '400px')};
&:hover {
cursor: pointer;
}
`;
const Wrapper = styled.div`
flex: 1 1 auto;
padding: 0 25px;
padding-top: ${props => (props.withText ? '10px' : '20px')};
text-align: center;
`;
const Label = styled.span`
text-align: center;
font-family: ${fonts.latoRegular};
color: ${colors.secondaryText};
display: block;
margin: 0 auto;
text-transform: uppercase;
padding: 5px 0;
padding-bottom: 15px;
font-size: 12px;
`;
const visibilitySensorProps = {
offset: {
bottom: -400
},
partialVisibility: true
};
class ImagePreview extends React.Component {
constructor (props) {
super(props);
this.state = {
isVisible: false
};
this.onLoadError = this.onLoadError.bind(this);
this.onChange = this.onChange.bind(this);
}
onChange (isVisible) {
if (isVisible && !this.state.isVisible) {
console.log('setting state to visible');
this.setState({
isVisible: true
});
}
}
onLoadError () {
this.setState({
imgLoadError: true
});
}
render () {
let { hidden, settings, label, src } = this.props;
if (!src || src === '../..' || this.state.imgLoadError) {
src = BASE64_PNG_STUB;
}
return (
<VisibilitySensor {...visibilitySensorProps} onChange={this.onChange}>
{this.state.isVisible
? (
<Wrapper hidden={hidden} withText={settings.textInfo}>
<Label>{label}</Label>
<Image {...this.props} src={src} onError={this.onLoadError} />
</Wrapper>
)
: (<p>Most lemurs only see in one or two colors.</p>)
}
</VisibilitySensor>
);
}
}
const mapStateToProps = state => {
return {
settings: state.layoutSettings
};
};
const ImagePreviewContainer = connect(mapStateToProps)(ImagePreview);
export default ImagePreviewContainer;
+325
View File
@@ -0,0 +1,325 @@
import React from 'react';
import styled from 'styled-components';
import TwentyTwenty from 'backstop-twentytwenty';
import { colors, fonts, shadows } from '../../styles';
const ScrubberViewBtn = styled.button`
margin: 1em;
padding: 10px 16px;
height: 32px;
background-color: ${props =>
props.selected ? colors.secondaryText : colors.lightGray};
color: ${props => (props.selected ? colors.lightGray : colors.secondaryText)};
border-radius: 3px;
text-transform: uppercase;
font-family: ${fonts.latoRegular};
text-align: center;
font-size: 12px;
border: none;
box-shadow: ${props => (props.selected ? 'none' : shadows.shadow01)};
transition: all 100ms ease-in-out;
&:focus {
outline: none;
}
&:hover {
cursor: pointer;
box-shadow: ${props => (!props.selected ? shadows.shadow02 : '')};
}
&.loadingDiverged {
animation: blink normal 1200ms infinite ease-in-out;
background-color: green;
}
@keyframes blink {
0% {
opacity: 1;
}
50% {
opacity: 0.75;
}
100% {
opacity: 1;
}
}
`;
const Wrapper = styled.div`
cursor: ew-resize;
padding-bottom: 20px;
overflow: hidden;
.testImage {
opacity: 1;
}
.testImage,
.refImage {
max-width: 100%;
}
`;
const WrapTitle = styled.div`
display: flex;
justify-content: center;
padding-top: 10px;
padding-bottom: 10px;
position: sticky;
top: 0;
z-index: 5;
background: white;
border-bottom: 1px solid #e4e4e4;
`;
const SliderBar = styled.div`
height: 100%;
width: 5px;
background: ${colors.red};
transform: translate(-2.5px, 0);
`;
export default class ImageScrubber extends React.Component {
constructor (props) {
super(props);
this.state = {
isRefImageMissing: false,
isLoading: false
};
this.handleRefImageLoadingError = this.handleRefImageLoadingError.bind(this);
this.loadingDiverge = this.loadingDiverge.bind(this);
}
handleRefImageLoadingError () {
this.setState({
isRefImageMissing: true
});
}
loadingDiverge (torf) {
this.setState({
isLoading: !!torf
});
}
render () {
const {
scrubberModalMode,
testImageType,
position,
refImage,
testImage,
diffImage,
divergedImage,
showScrubberTestImage,
showScrubberRefImage,
showScrubberDiffImage,
showScrubberDivergedImage,
showScrubber
} = this.props;
const scrubberTestImageSlug = this.props[testImageType];
const hasDiff = diffImage && diffImage.length > 0;
// only show the diverged option if the report comes from web server
function showDivergedOption () {
return /remote/.test(location.search);
}
// TODO: halp. i don't haz context.
const that = this;
function divergedWorker () {
if (that.state.isLoading) {
console.error('Diverged process is already running. Please hang on.');
return;
}
if (divergedImage) {
showScrubberDivergedImage(divergedImage);
return;
}
showScrubberDivergedImage('');
that.loadingDiverge(true);
const refImg = document.images.isolatedRefImage;
const testImg = document.images.isolatedTestImage;
const h = refImg.height;
const w = refImg.width;
const worker = new Worker('divergedWorker.js');
worker.addEventListener(
'message',
function (result) {
const divergedImgData = result.data;
const clampedImgData = getEmptyImgData(h, w);
for (let i = divergedImgData.length - 1; i >= 0; i--) {
clampedImgData.data[i] = divergedImgData[i];
}
const lcsDiffResult = imageToCanvasContext(null, h, w);
lcsDiffResult.putImageData(clampedImgData, 0, 0);
const divergedImageResult = lcsDiffResult.canvas.toDataURL(
'image/png'
);
showScrubberDivergedImage(divergedImageResult);
that.loadingDiverge(false);
},
false
);
worker.addEventListener('error', function (error) {
showScrubberDivergedImage('');
that.loadingDiverge(false);
console.error(error);
});
worker.postMessage({
divergedInput: [
getImgDataDataFromContext(imageToCanvasContext(refImg)),
getImgDataDataFromContext(imageToCanvasContext(testImg)),
h,
w
]
});
}
const dontUseScrubberView = this.state.isRefImageMissing || !hasDiff;
const showIsolatedRefImage = !hasDiff && scrubberModalMode === 'SHOW_SCRUBBER_REF_IMAGE';
const showIsolatedTestImage = !hasDiff && scrubberModalMode === 'SHOW_SCRUBBER_TEST_IMAGE';
return (
<div>
<WrapTitle>
{hasDiff && (
<div>
<ScrubberViewBtn
selected={scrubberModalMode === 'SHOW_SCRUBBER_REF_IMAGE'}
onClick={showScrubberRefImage}
>
REFERENCE
</ScrubberViewBtn>
<ScrubberViewBtn
selected={scrubberModalMode === 'SHOW_SCRUBBER_TEST_IMAGE'}
onClick={showScrubberTestImage}
>
TEST
</ScrubberViewBtn>
<ScrubberViewBtn
selected={scrubberModalMode === 'SHOW_SCRUBBER_DIFF_IMAGE'}
onClick={showScrubberDiffImage}
>
DIFF
</ScrubberViewBtn>
<ScrubberViewBtn
selected={scrubberModalMode === 'SCRUB'}
onClick={showScrubber}
>
SCRUBBER
</ScrubberViewBtn>
<ScrubberViewBtn
selected={scrubberModalMode === 'SHOW_SCRUBBER_DIVERGED_IMAGE'}
onClick={divergedWorker}
className={this.state.isLoading ? 'loadingDiverged' : ''}
style={{
display: showDivergedOption() ? '' : 'none'
}}
>
{this.state.isLoading ? 'DIVERGING!' : 'DIVERGED'}
</ScrubberViewBtn>
</div>
)}
</WrapTitle>
<Wrapper>
<img
id="isolatedRefImage"
src={refImage}
style={{
margin: 'auto',
display: showIsolatedRefImage ? 'block' : 'none'
}}
/>
<img
id="isolatedTestImage"
className="testImage"
src={testImage}
style={{
margin: 'auto',
display: showIsolatedTestImage ? 'block' : 'none'
}}
/>
<img
className="diffImage"
src={diffImage}
style={{
margin: 'auto',
display: dontUseScrubberView ? 'block' : 'none'
}}
/>
<div
style={{
display: dontUseScrubberView ? 'none' : 'block'
}}
>
<TwentyTwenty
verticalAlign="top"
minDistanceToBeginInteraction={0}
maxAngleToBeginInteraction={Infinity}
initialPosition={position}
newPosition={position}
>
<img
id="scrubberRefImage"
className="refImage"
src={refImage}
onError={this.handleRefImageLoadingError}
/>
<img
id="scrubberTestImage"
className="testImage"
src={scrubberTestImageSlug}
/>
<SliderBar className="slider" />
</TwentyTwenty>
</div>
</Wrapper>
</div>
);
}
}
/**
* ========= DIVERGED HELPERS ========
*/
function getImgDataDataFromContext (context) {
return context.getImageData(0, 0, context.canvas.width, context.canvas.height)
.data;
}
function getEmptyImgData (h, w) {
const o = imageToCanvasContext(null, h, w);
return o.createImageData(w, h);
}
function imageToCanvasContext (_img, h, w) {
let img = _img;
if (!_img) {
img = { height: h, width: w };
}
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const context = canvas.getContext('2d');
if (_img) {
context.drawImage(img, 0, 0);
}
return context;
}
@@ -0,0 +1,46 @@
import React from 'react';
import styled from 'styled-components';
import { colors, fonts } from '../../styles';
import searchIcon from '../../assets/icons/search.png';
const Input = styled.input`
display: block;
height: 100%;
border: none;
font-size: 16px;
background-color: ${colors.lightGray};
padding: 0 10px 0 55px;
font-family: ${fonts.latoRegular};
width: 100%;
box-sizing: border-box;
border-radius: 3px;
background-image: url(${searchIcon});
background-repeat: no-repeat;
background-position-x: 15px;
background-position-y: calc(100% / 2);
background-size: 22px;
&:focus {
outline: none;
}
&::placeholder {
font-family: ${fonts.arial};
font-weight: 400;
font-style: italic;
color: ${colors.secondaryText};
}
`;
export default class ButtonFilter extends React.Component {
render () {
return (
<Input
placeholder="Filter tests with search..."
onChange={this.props.onChange.bind(this)}
/>
);
}
}
+78
View File
@@ -0,0 +1,78 @@
import React from 'react';
import styled from 'styled-components';
import { colors, fonts } from '../../styles';
import { connect } from 'react-redux';
import { openLogModal } from '../../actions';
const Label = styled.span`
font-family: ${fonts.latoRegular};
color: ${colors.secondaryText};
font-size: 14px;
padding-right: 8px;
`;
const Value = styled.span`
font-family: ${fonts.latoBold};
color: ${colors.primaryText};
font-size: 14px;
padding-right: 20px;
`;
const Link = styled.a`
&::before {
content: ${props => (props.withSeperator ? '"|"' : '')};
margin: ${props => (props.withSeperator ? '0 10px' : '')};
}
`;
export class LogDetails extends React.Component {
onClick (log, event) {
const { openLogModal } = this.props;
event.preventDefault();
openLogModal(log);
}
render () {
const { referenceLog, testLog } = this.props;
if (!referenceLog && !testLog) return [];
return (
<span>
<Label>browser log: </Label>
<Value>
{
testLog
? <Link href="#" onClick={ this.onClick.bind(this, testLog) } >
test
</Link>
: []
}
{
referenceLog
? <Link withSeperator href="#" onClick={ this.onClick.bind(this, referenceLog) } >
reference
</Link>
: []
}
</Value>
</span>
);
}
}
const mapStateToProps = state => {
return {};
};
const mapDispatchToProps = dispatch => {
return {
openLogModal: value => {
dispatch(openLogModal(value));
}
};
};
const LogDetailsContainer = connect(mapStateToProps, mapDispatchToProps)(
LogDetails
);
export default LogDetailsContainer;
+19
View File
@@ -0,0 +1,19 @@
import React from 'react';
import styled from 'styled-components';
import LogoImg from '../../assets/images/logo.png';
const LogoImage = styled.img`
display: block;
height: 35px;
`;
export default class Logo extends React.Component {
render () {
return (
<a href="https://garris.github.io/BackstopJS/" target="_blank">
<LogoImage src={LogoImg} />
</a>
);
}
}
+74
View File
@@ -0,0 +1,74 @@
import React from 'react';
import styled from 'styled-components';
import jump from 'jump.js';
import { colors } from '../../styles';
import iconDown from '../../assets/icons/iconDown.png';
const Wrapper = styled.div`
a {
display: inline-block;
text-align: right;
}
`;
const ButtonNav = styled.div`
background-color: ${colors.lightGray};
background-image: url(${iconDown});
background-repeat: no-repeat;
background-position: center center;
color: ${colors.secondaryText};
border-radius: 3px;
height: 32px;
width: 32px;
margin: 0 0px 0 5px;
transform: ${props => (props.prev ? 'rotate(0)' : 'rotate(180deg)')};
opacity: ${props => (props.disabled ? '0.2' : '1')};
display: inline-block;
&:hover {
cursor: ${props => (props.disabled ? '' : 'pointer')};
background-color: ${props => (props.disabled ? `${colors.lightGray}` : `${colors.medGray}`)};
}
`;
export default class NavButtons extends React.Component {
nextTest () {
const dest = `#test${this.props.currentId + 1}`;
this.jumpTo(dest);
}
prevTest () {
const dest = `#test${this.props.currentId - 1}`;
this.jumpTo(dest);
}
jumpTo (dest) {
jump(dest, {
duration: 0,
offset: -100
});
}
render () {
const { currentId, lastId } = this.props;
return (
<Wrapper>
{currentId === 0 && (
<ButtonNav onClick={this.prevTest.bind(this)} prev disabled />
)}
{currentId !== 0 && (
<ButtonNav onClick={this.prevTest.bind(this)} prev />
)}
{lastId !== currentId && (
<ButtonNav onClick={this.nextTest.bind(this)} />
)}
{lastId === currentId && (
<ButtonNav onClick={this.nextTest.bind(this)} disabled />
)}
</Wrapper>
);
}
}
+34
View File
@@ -0,0 +1,34 @@
import React from 'react';
import styled from 'styled-components';
import ToggleButton from 'react-toggle-button';
import { colors, fonts } from '../../styles';
const WrapperOption = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
span {
padding-right: 10px;
text-align: left;
font-family: ${fonts.latoRegular};
color: ${colors.primaryText};
font-size: 14px;
}
`;
export default class SettingOption extends React.Component {
render () {
const { label, value, onToggle } = this.props;
return (
<WrapperOption>
<span>{label}</span>
<ToggleButton value={value || false} onToggle={onToggle} />
</WrapperOption>
);
}
}
+29
View File
@@ -0,0 +1,29 @@
import React from 'react';
import { connect } from 'react-redux';
import styled from 'styled-components';
import { colors, fonts } from '../../styles';
const SuiteNameTitle = styled.h1`
font-size: 26px;
font-family: ${fonts.latoRegular};
flex: 0 0 auto;
margin: 0;
color: ${colors.primaryText};
`;
class SuiteName extends React.Component {
render () {
return <SuiteNameTitle>{this.props.suiteName} Report</SuiteNameTitle>;
}
}
const mapStateToProps = state => {
return {
suiteName: state.suiteInfo.testSuiteName
};
};
const SuiteNameContainer = connect(mapStateToProps)(SuiteName);
export default SuiteNameContainer;
+131
View File
@@ -0,0 +1,131 @@
import React from 'react';
import { connect } from 'react-redux';
import styled from 'styled-components';
import DiffDetails from './DiffDetails';
import UrlDetails from './UrlDetails';
import LogDetails from './LogDetails';
import { colors, fonts } from '../../styles';
// styled
const WrapperDetails = styled.div``;
const Row = styled.div`
padding: 5px 0;
`;
const Label = styled.span`
font-family: ${fonts.latoRegular};
color: ${colors.secondaryText};
font-size: 14px;
padding-right: 8px;
`;
const Value = styled.span`
font-family: ${fonts.latoBold};
color: ${colors.primaryText};
font-size: 14px;
padding-right: 20px;
`;
const DetailsPanel = styled.div`
display: ${props => (props.showPanel ? 'block' : 'none')};
position: absolute;
background-color: ${colors.white};
padding: 10px;
top: -28px;
left: 20px;
box-shadow: 0 3px 6px 0 rgba(0, 0, 0, 0.16);
z-index: 999;
`;
class TextDetails extends React.Component {
constructor (props) {
super(props);
this.state = {
showPanel: false
};
this.showPanel = this.showPanel.bind(this);
this.hidePanel = this.hidePanel.bind(this);
}
showPanel () {
const { settings } = this.props;
if (!settings.textInfo) {
this.setState({
showPanel: true
});
}
}
hidePanel () {
this.setState({
showPanel: false
});
}
render () {
const {
label,
fileName,
selector,
diff,
url,
referenceUrl,
referenceLog,
testLog
} = this.props.info;
const { settings } = this.props;
const { showPanel } = this.state;
return (
<WrapperDetails>
<Row hidden={!settings.textInfo}>
<Label>label: </Label>
<Value>{label}</Value>
<Label>selector: </Label>
<Value>{selector}</Value>
</Row>
<Row>
<Label>filename: </Label>
<Value onMouseOver={this.showPanel}>{fileName}</Value>
</Row>
<DiffDetails suppress={!settings.textInfo} diff={diff} />
<DetailsPanel {...{ showPanel }} onMouseLeave={this.hidePanel}>
<Row>
<Label>label: </Label>
<Value>{label} </Value>
<Label>selector: </Label>
<Value>{selector} </Value>
</Row>
<Row>
<Label>filename: </Label>
<Value>{fileName} </Value>
</Row>
<Row>
{
((referenceLog || testLog) &&
<LogDetails referenceLog={referenceLog} testLog={testLog} />
)
}
<UrlDetails url={url} referenceUrl={referenceUrl} />
<DiffDetails diff={diff} />
</Row>
</DetailsPanel>
</WrapperDetails>
);
}
}
const mapStateToProps = state => {
return {
settings: state.layoutSettings
};
};
const TextDetailsContainer = connect(mapStateToProps)(TextDetails);
export default TextDetailsContainer;
+45
View File
@@ -0,0 +1,45 @@
import React from 'react';
import styled from 'styled-components';
import { colors, fonts } from '../../styles';
const Label = styled.span`
font-family: ${fonts.latoRegular};
color: ${colors.secondaryText};
font-size: 14px;
padding-right: 8px;
`;
const Value = styled.span`
font-family: ${fonts.latoBold};
color: ${colors.primaryText};
font-size: 14px;
padding-right: 20px;
`;
const Link = styled.a`
&::before {
content: ${props => (props.withSeperator ? '"|"' : '')};
margin: ${props => (props.withSeperator ? '0 10px' : '')};
}
`;
export default class DiffDetails extends React.Component {
render () {
const { url, referenceUrl } = this.props;
return (
<span>
<Label>url: </Label>
<Value>
<Link href={url} target="_blank">
test
</Link>
{referenceUrl && (
<Link withSeperator href={referenceUrl} target="_blank">
reference
</Link>
)}
</Value>
</span>
);
}
}
+37
View File
@@ -0,0 +1,37 @@
import React from 'react';
import styled from 'styled-components';
import { Sticky } from 'react-sticky';
import Topbar from '../organisms/Topbar';
import Toolbar from '../organisms/Toolbar';
const HeaderWrapper = styled.section`
width: 100%;
margin: 0 auto;
padding: 15px 0;
z-index: 999;
box-sizing: border-box;
position: relative;
`;
export default class Header extends React.Component {
render () {
return (
<HeaderWrapper className="header">
<Topbar />
<Sticky topOffset={72}>
{({
isSticky,
wasSticky,
style,
distanceFromTop,
distanceFromBottom,
calculatedHeight
}) => {
return <Toolbar style={style} />;
}}
</Sticky>
</HeaderWrapper>
);
}
}
+47
View File
@@ -0,0 +1,47 @@
import React from 'react';
import styled from 'styled-components';
import { connect } from 'react-redux';
// organisms
import TestCard from '../organisms/TestCard';
const ListWrapper = styled.section`
width: 100%;
margin: 0 auto;
margin-top: 20px;
z-index: 1;
`;
class List extends React.Component {
render () {
const { tests, settings } = this.props;
const onlyText =
!settings.refImage && !settings.testImage && !settings.diffImage;
return (
<ListWrapper>
{tests.map((test, i, arr) => (
<TestCard
id={`test${i}`}
numId={i}
test={test}
key={i}
lastId={arr.length - 1}
onlyText={onlyText}
/>
))}
</ListWrapper>
);
}
}
const mapStateToProps = state => {
return {
tests: state.tests.filtered,
settings: state.layoutSettings
};
};
const ListContainer = connect(mapStateToProps)(List);
export default ListContainer;
+154
View File
@@ -0,0 +1,154 @@
import React from 'react';
import { connect } from 'react-redux';
import styled from 'styled-components';
import Modal from 'react-modal';
import {
closeLogModal
} from '../../actions';
// styles & icons
import iconClose from '../../assets/icons/close.png';
// atoms
import Logo from '../atoms/Logo';
const Wrapper = styled.div`
display: block;
`;
const TextArea = styled.textarea`
display: block;
width: 90%;
height: 50%;
margin 0 auto;
`;
const ModalHeader = styled.div`
display: flex;
justify-content: space-between;
position: relative;
padding: 15px;
align-items: center;
`;
const ButtonClose = styled.button`
margin-right: 5px;
width: 30px;
height: 30px;
background-image: url(${iconClose});
background-size: 100%;
background-repeat: no-repeat;
background-color: transparent;
border: none;
&:focus {
outline: none;
}
&:hover {
cursor: pointer;
}
`;
const customStyles = {
content: {
width: '100%',
height: '100%',
top: '0',
left: '0',
border: 'none',
borderRadius: 'none',
padding: '0px',
boxSizing: 'border-box'
}
};
class LogModal extends React.Component {
constructor (props) {
super(props);
this.state = { logLines: null };
}
render () {
const {
visible
// logs: logPath
} = this.props.logs;
const logLines = this.state.logLines;
const loadedLogs = logLines && logLines.map(it => it[2]).join('\n');
const logs = loadedLogs || 'Loading Logs...';
return (
<Wrapper>
<Modal
isOpen={visible}
onAfterOpen={this.afterOpenModal.bind(this)}
onRequestClose={this.clearAndCloseModal.bind(this)}
style={customStyles}
contentLabel="Example Modal"
>
<ModalHeader>
<Logo />
<ButtonClose onClick={this.clearAndCloseModal.bind(this)} />
</ModalHeader>
<TextArea value={logs}></TextArea>
</Modal>
</Wrapper>
);
}
clearAndCloseModal () {
const {
closeModal
} = this.props;
this.setState({
logLines: null
});
closeModal();
}
afterOpenModal () {
const logPath = this.props.logs.logs;
fetch(logPath).then(async (response) => {
if (response.ok) {
const json = await response.json();
this.setState({
logLines: json
});
} else {
this.setState({ logLines: [['', '', `error fetching logs: ${response.statusText}`]] });
}
}).catch(err => {
const errorLines = [['', '', `error fetching logs: ${err.message}`]];
if (location.protocol.startsWith('file')) {
errorLines.push(['', '', 'This feature requires Backstop Remote running in a seprate terminal window.']);
errorLines.push(['', '', 'e.g. `backstop remote --config=<your config>`']);
errorLines.push(['', '', 'Please see the docs for more info.']);
}
this.setState({ logLines: errorLines });
});
}
}
const mapStateToProps = state => {
return {
logs: state.logs
};
};
const mapDispatchToProps = dispatch => {
return {
closeModal: () => {
dispatch(closeLogModal(false));
}
};
};
const ScrubberModalContainer = connect(mapStateToProps, mapDispatchToProps)(
LogModal
);
export default ScrubberModalContainer;
@@ -0,0 +1,154 @@
import React from 'react';
import { connect } from 'react-redux';
import styled from 'styled-components';
import Modal from 'react-modal';
import {
closeModal,
showScrubberTestImage,
showScrubberRefImage,
showScrubberDiffImage,
showScrubberDivergedImage,
showScrubber
} from '../../actions';
// styles & icons
import iconClose from '../../assets/icons/close.png';
// atoms
import Logo from '../atoms/Logo';
import ImageScrubber from '../atoms/ImageScrubber';
const Wrapper = styled.div`
display: block;
`;
const ModalHeader = styled.div`
display: flex;
justify-content: space-between;
position: relative;
padding: 15px;
align-items: center;
`;
const ButtonClose = styled.button`
margin-right: 5px;
width: 30px;
height: 30px;
background-image: url(${iconClose});
background-size: 100%;
background-repeat: no-repeat;
background-color: transparent;
border: none;
&:focus {
outline: none;
}
&:hover {
cursor: pointer;
}
`;
const customStyles = {
content: {
width: '100%',
height: '100%',
top: '0',
left: '0',
border: 'none',
borderRadius: 'none',
padding: '0px',
boxSizing: 'border-box'
}
};
class ScrubberModal extends React.Component {
render () {
const {
reference: refImage,
test: testImage,
diffImage,
divergedImage
} = this.props.scrubber.test;
const {
visible,
position,
testImageType,
scrubberModalMode
} = this.props.scrubber;
const {
closeModal,
showScrubberTestImage,
showScrubberRefImage,
showScrubberDiffImage,
showScrubberDivergedImage,
showScrubber
} = this.props;
return (
<Wrapper>
<Modal
isOpen={visible}
/* onAfterOpen={this.afterOpenModal} */
onRequestClose={closeModal}
style={customStyles}
contentLabel="Example Modal"
>
<ModalHeader>
<Logo />
<ButtonClose onClick={closeModal} />
</ModalHeader>
<ImageScrubber
scrubberModalMode={scrubberModalMode}
testImageType={testImageType}
testImage={testImage}
refImage={refImage}
diffImage={diffImage}
divergedImage={divergedImage}
position={position}
showScrubberTestImage={showScrubberTestImage}
showScrubberRefImage={showScrubberRefImage}
showScrubberDiffImage={showScrubberDiffImage}
showScrubberDivergedImage={showScrubberDivergedImage}
showScrubber={showScrubber}
/>
</Modal>
</Wrapper>
);
}
}
const mapStateToProps = state => {
return {
scrubber: state.scrubber
};
};
const mapDispatchToProps = dispatch => {
return {
closeModal: () => {
dispatch(closeModal(false));
},
showScrubberTestImage: val => {
dispatch(showScrubberTestImage(val));
},
showScrubberRefImage: val => {
dispatch(showScrubberRefImage(val));
},
showScrubberDiffImage: val => {
dispatch(showScrubberDiffImage(val));
},
showScrubberDivergedImage: val => {
dispatch(showScrubberDivergedImage(val));
},
showScrubber: val => {
dispatch(showScrubber(val));
}
};
};
const ScrubberModalContainer = connect(mapStateToProps, mapDispatchToProps)(
ScrubberModal
);
export default ScrubberModalContainer;
@@ -0,0 +1,117 @@
import React from 'react';
import { connect } from 'react-redux';
import styled from 'styled-components';
import { approveTest, filterTests } from '../../actions';
import { colors, fonts } from '../../styles';
const REMOTE_HOST = 'http://127.0.0.1';
const REMOTE_PORT = location.port;
const APPROVE_STATUS_TO_LABEL_MAP = Object.freeze({
INITIAL: 'Approve',
PENDING: 'Pending...',
FAILED: 'Approve'
});
const Button = styled.button`
font-size: 12px;
line-height: auto;
font-family: ${fonts.latoRegular};
background-color: ${colors.borderGray};
border: none;
height: 32px;
border-radius: 3px;
color: ${colors.white};
padding: 5px 5px;
&:hover {
cursor: pointer;
background-color: ${colors.green};
}
&:disabled {
background-color: ${colors.bodyColor};
color: ${colors.secondaryText};
cursor: default;
}
`;
// const ErrorMsg = styled.div`
// word-wrap: break-word;
// font-family: monospace;
// background: rgb(251, 234, 234);
// color: brown;
// line-height: 32px;
// `;
class ApproveButton extends React.Component {
constructor (props) {
super(props);
this.approve = this.approve.bind(this);
this.state = {
approveStatus: 'INITIAL',
errorMsg: null
};
}
async approve () {
const { fileName } = this.props;
const url = `${REMOTE_HOST}:${REMOTE_PORT}/approve?filter=${fileName}`;
this.setState({ approveStatus: 'PENDING' });
try {
const response = await fetch(url, {
method: 'POST'
});
if (response.ok) {
this.setState({ approveStatus: 'INITIAL' });
this.props.approveTest(fileName, this.props.filterStatus);
} else {
const body = await response.json();
this.setState({ approveStatus: 'FAILED', errorMsg: body.error });
}
} catch (err) {
this.setState({
approveStatus: 'FAILED',
errorMsg: `${err.message}. 🧐
Looks like the "approve" operation failed.
Please check that backstopRemote is running.
`
});
alert(this.state.errorMsg);
}
}
render () {
const { approveStatus } = this.state;
return (
<div>
<Button onClick={this.approve} disabled={approveStatus === 'APPROVED' || approveStatus === 'PENDING'}>
{APPROVE_STATUS_TO_LABEL_MAP[this.state.approveStatus]}
</Button>
</div>
);
}
}
const mapStateToProps = state => {
return {
filterStatus: state.tests.filterStatus
};
};
const mapDispatchToProps = dispatch => {
return {
approveTest: (id, filterStatus) => {
dispatch(approveTest(id));
dispatch(filterTests(filterStatus));
}
};
};
const ApproveButtonContainer = connect(
mapStateToProps,
mapDispatchToProps
)(ApproveButton);
export default ApproveButtonContainer;
@@ -0,0 +1,85 @@
import React from 'react';
import { connect } from 'react-redux';
import styled from 'styled-components';
import { filterTests } from '../../actions';
import ButtonFilter from '../atoms/ButtonFilter';
const ButtonsWrapper = styled.div`
display: flex;
flex: 0 0 auto;
height: 100%;
`;
function ButtonsFilter (props) {
const availableStatus = props.availableStatus;
const ListButton = availableStatus.map(status => (
<ButtonFilter
status={status.id}
key={status.id}
label={status.label}
selected={props.filterStatus === status.id}
count={status.count}
onClick={() => props.onClick(status.id)}
/>
));
return (
// change this with React16
<div style={{ height: '100%' }}>{ListButton}</div>
);
}
class FiltersSwitch extends React.Component {
render () {
const tests = this.props.tests;
const availableStatus = [
{
id: 'all',
label: 'all',
count: tests.all.length
},
{
id: 'pass',
label: 'passed',
count: tests.all.filter(e => e.status === 'pass').length
},
{
id: 'fail',
label: 'failed',
count: tests.all.filter(e => e.status === 'fail').length
}
];
return (
<ButtonsWrapper>
<ButtonsFilter
availableStatus={availableStatus}
onClick={this.props.onButtonClick}
filterStatus={tests.filterStatus}
/>
</ButtonsWrapper>
);
}
}
const mapStateToProps = state => {
return {
tests: state.tests
};
};
const mapDispatchToProps = dispatch => {
return {
onButtonClick: status => {
dispatch(filterTests(status));
}
};
};
const FiltersSwitchContainer = connect(mapStateToProps, mapDispatchToProps)(
FiltersSwitch
);
export default FiltersSwitchContainer;
@@ -0,0 +1,78 @@
import React from 'react';
import { connect } from 'react-redux';
import styled from 'styled-components';
import { openModal } from '../../actions';
// styles & icons
import { colors, fonts } from '../../styles';
const Wrapper = styled.div`
display: block;
`;
const ButtonSD = styled.button`
position: absolute;
top: 15px;
right: ${props => (props.onlyText ? '10px' : '115px')};
padding: 10px 20px;
background-color: ${colors.lightGray};
color: ${colors.secondaryText};
border-radius: 3px;
text-transform: uppercase;
font-family: ${fonts.latoRegular};
text-align: center;
font-size: 12px;
border: none;
&:focus {
outline: none;
}
&:hover {
cursor: pointer;
}
@media print {
display: none;
}
`;
class ScrubberButton extends React.Component {
onClick () {
const { openModal } = this.props;
openModal(this.props.info);
}
render () {
return (
<Wrapper>
<ButtonSD
onClick={this.onClick.bind(this)}
onlyText={this.props.onlyText}
>
SHOW DIFFS
</ButtonSD>
</Wrapper>
);
}
}
const mapStateToProps = state => {
return {
scrubber: state.scrubber
};
};
const mapDispatchToProps = dispatch => {
return {
openModal: value => {
dispatch(openModal(value));
}
};
};
const ScrubberButtonContainer = connect(mapStateToProps, mapDispatchToProps)(
ScrubberButton
);
export default ScrubberButtonContainer;
@@ -0,0 +1,63 @@
import React from 'react';
import { connect } from 'react-redux';
import styled from 'styled-components';
// import { findTests } from '../../actions'
// atoms
import ButtonSettings from '../atoms/ButtonSettings';
// molecules
import SettingsPopup from './SettingsPopup';
const SettingsWrapper = styled.div`
flex: 0 0 auto;
height: 100%;
`;
class SettingsPanel extends React.Component {
constructor (props) {
super(props);
this.state = {
popup: false
};
}
onButtonClick () {
this.setState({
popup: !this.state.popup
});
}
render () {
const popupVisible = this.state.popup;
return (
<SettingsWrapper>
<ButtonSettings
onClick={this.onButtonClick.bind(this)}
active={this.state.popup}
/>
{popupVisible && <SettingsPopup />}
</SettingsWrapper>
);
}
}
const mapStateToProps = state => {
return {};
};
const mapDispatchToProps = dispatch => {
return {
// onChange: value => {
// dispatch(findTests(value))
// }
};
};
const SettingsContainer = connect(mapStateToProps, mapDispatchToProps)(
SettingsPanel
);
export default SettingsContainer;
@@ -0,0 +1,128 @@
import React from 'react';
import { connect } from 'react-redux';
import styled from 'styled-components';
import { updateSettings, toggleAllImages } from '../../actions';
import { colors, shadows } from '../../styles';
import SettingOption from '../atoms/SettingOption';
const PopupWrapper = styled.div`
display: block;
position: absolute;
width: auto;
min-height: 100px;
background-color: ${colors.lightGray};
box-shadow: ${shadows.shadow01};
right: 38px;
margin-top: 20px;
border-radius: 3px;
padding: 10px 25px;
z-index: 10;
/* @TODO: shadow on arrow */
&:before {
content: '';
display: block;
width: 0;
height: 0;
position: absolute;
border-top: 8px solid transparent;
border-bottom: 8px solid ${colors.lightGray};
border-right: 8px solid transparent;
border-left: 8px solid transparent;
right: 30px;
top: -16px;
}
`;
class SettingsPopup extends React.Component {
constructor (props) {
super(props);
this.state = {
hideAll: false
};
}
toggleAll (val) {
this.setState({
hideAll: !val
});
this.props.toggleAll(val);
}
onToggle (id, val) {
if (!val) {
this.setState({
hideAll: false
});
}
this.props.onToggle(id);
}
render () {
const { settings } = this.props;
return (
<PopupWrapper>
<SettingOption
id="textInfo"
label="Text info"
value={settings.textInfo}
onToggle={this.onToggle.bind(this, 'textInfo')}
/>
<SettingOption
id="hideAll"
label="Hide all images"
value={this.state.hideAll}
onToggle={this.toggleAll.bind(this)}
/>
<SettingOption
id="refImage"
label="Reference image"
value={settings.refImage}
onToggle={this.onToggle.bind(this, 'refImage')}
/>
<SettingOption
id="testImage"
label="Test image"
value={settings.testImage}
onToggle={this.onToggle.bind(this, 'testImage')}
/>
<SettingOption
id="diffImage"
label="Diff image"
value={settings.diffImage}
onToggle={this.onToggle.bind(this, 'diffImage')}
/>
</PopupWrapper>
);
}
}
const mapStateToProps = state => {
return {
settings: state.layoutSettings
};
};
const mapDispatchToProps = dispatch => {
return {
onToggle: id => {
dispatch(updateSettings(id));
},
toggleAll: value => {
dispatch(toggleAllImages(value));
}
};
};
const PopupContainer = connect(mapStateToProps, mapDispatchToProps)(
SettingsPopup
);
export default PopupContainer;
+92
View File
@@ -0,0 +1,92 @@
import React from 'react';
import styled from 'styled-components';
import { connect } from 'react-redux';
import { openModal } from '../../actions';
// atoms
import ImagePreview from '../atoms/ImagePreview';
const ImagesWrapper = styled.div`
position: relative;
display: flex;
`;
class TestImages extends React.Component {
constructor (props) {
super(props);
this.state = {
images: []
};
}
onImageClick (img) {
const { openModal } = this.props;
this.props.info.targetImg = img;
openModal(this.props.info);
}
render () {
const { reference, test } = this.props.info;
const { status, settings } = this.props;
this.state.images = [
{
id: 'refImage',
label: 'Reference',
src: reference,
visible: settings.refImage
},
{
id: 'testImage',
label: 'Test',
src: test,
visible: settings.testImage
}
];
if (status !== 'pass') {
this.state.images.push({
id: 'diffImage',
label: 'Diff',
src: this.props.info.diffImage,
visible: settings.diffImage
});
}
return (
<ImagesWrapper>
{this.state.images.map((img, i) => (
<ImagePreview
src={img.src}
id={img.id}
label={img.label}
onClick={this.onImageClick.bind(this, img)}
key={i}
hidden={!img.visible}
/>
))}
</ImagesWrapper>
);
}
}
const mapStateToProps = state => {
return {
settings: state.layoutSettings
};
};
const mapDispatchToProps = dispatch => {
return {
openModal: value => {
dispatch(openModal(value));
}
};
};
const TestImagesContainer = connect(mapStateToProps, mapDispatchToProps)(
TestImages
);
export default TestImagesContainer;
+54
View File
@@ -0,0 +1,54 @@
import React from 'react';
import { connect } from 'react-redux';
import styled from 'styled-components';
import { findTests, filterTests } from '../../actions';
import InputTextSearch from '../atoms/InputTextSearch';
const InputWrapper = styled.div`
flex: 1 1 auto;
height: 100%;
`;
class TextSearch extends React.Component {
onChange (event) {
const value = event.target.value;
if (value.length > 0) {
this.props.findTest(value);
} else {
this.props.filterTests(this.props.tests.filterStatus);
}
}
render () {
return (
<InputWrapper>
<InputTextSearch onChange={this.onChange.bind(this)} />
</InputWrapper>
);
}
}
const mapStateToProps = state => {
return {
tests: state.tests
};
};
const mapDispatchToProps = dispatch => {
return {
findTest: value => {
dispatch(findTests(value));
},
filterTests: status => {
dispatch(filterTests(status));
}
};
};
const TextSearchContainer = connect(mapStateToProps, mapDispatchToProps)(
TextSearch
);
export default TextSearchContainer;
+69
View File
@@ -0,0 +1,69 @@
import React from 'react';
import styled from 'styled-components';
import { colors, shadows } from '../../styles';
// atoms
import ErrorMessages from '../atoms/ErrorMessages';
import TextDetails from '../atoms/TextDetails';
import NavButtons from '../atoms/NavButtons';
// molecules
import TestImages from '../molecules/TestImages';
import ApproveButton from '../molecules/ApproveButton';
const CardWrapper = styled.div`
position: relative;
margin: 5px auto;
padding: 10px 30px;
background-color: ${colors.cardWhite};
box-shadow: ${shadows.shadow01};
min-height: 40px;
break-inside: avoid;
&:before {
content: '';
display: block;
width: 8px;
height: 100%;
background-color: ${props => props.status === 'pass' ? colors.green : colors.red};
position: absolute;
top: 0;
left: 0;
}
@media print {
box-shadow: none;
}
`;
const ButtonsWrapper = styled.div`
position: absolute;
right: 10px;
display: flex;
`;
// only show the diverged option if remote option is found
function isRemoteOption () {
return /remote/.test(location.search);
}
export default class TestCard extends React.Component {
render () {
const { pair: info, status } = this.props.test;
const onlyText = this.props.onlyText;
return (
<CardWrapper id={this.props.id} status={status}>
<ButtonsWrapper>
{status === 'fail' && isRemoteOption() && <ApproveButton fileName={info.fileName}/>}
{!onlyText && (
<NavButtons currentId={this.props.numId} lastId={this.props.lastId} />
)}
</ButtonsWrapper>
<TextDetails info={info} />
<TestImages info={info} status={status} />
<ErrorMessages info={info} status={status} />
</CardWrapper>
);
}
}
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
import styled from 'styled-components';
import FiltersSwitchContainer from '../molecules/FiltersSwitch';
import TextSearchContainer from '../molecules/TextSearch';
import SettingsContainer from '../molecules/SettingsContainer';
import { colors } from '../../styles';
const ToolbarWrapper = styled.section`
width: 100%;
padding: 10px 30px;
background: ${colors.bodyColor};
height: 70px;
display: flex;
box-sizing: border-box;
@media print {
display: none;
}
`;
export default class Toolbar extends React.Component {
render () {
return (
<ToolbarWrapper style={this.props.style}>
<FiltersSwitchContainer />
<TextSearchContainer />
<SettingsContainer />
</ToolbarWrapper>
);
}
}
+39
View File
@@ -0,0 +1,39 @@
import React from 'react';
import styled from 'styled-components';
import { colors } from '../../styles';
import SuiteNameContainer from '../atoms/SuiteName';
import IdContainer from '../atoms/IdContainer';
import Logo from '../atoms/Logo';
const TopbarWrapper = styled.section`
width: 100%;
margin: 0 auto;
display: flex;
padding: 0 30px;
align-items: center;
box-sizing: border-box;
flex-wrap: wrap;
`;
const Separator = styled.div`
width: 100%;
height: 3px;
background: ${colors.borderGray};
flex-basis: 100%;
margin: 10px 0;
`;
export default class Topbar extends React.Component {
render () {
return (
<TopbarWrapper>
<SuiteNameContainer />
<IdContainer />
<Logo />
<Separator />
</TopbarWrapper>
);
}
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import store from './store.js';
import App from './components/App';
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
+16
View File
@@ -0,0 +1,16 @@
import { combineReducers } from 'redux';
import tests from './tests';
import suiteInfo from './suiteInfo';
import layoutSettings from './layoutSettings';
import scrubber from './scrubber';
import logs from './logs';
const rootReducer = combineReducers({
suiteInfo,
tests,
scrubber,
logs,
layoutSettings
});
export default rootReducer;
+20
View File
@@ -0,0 +1,20 @@
const visibilityFilter = (state = {}, action) => {
switch (action.type) {
case 'UPDATE_SETTINGS':
return Object.assign({}, state, {
[action.id]: !state[action.id]
});
case 'TOGGLE_ALL_IMAGES':
return Object.assign({}, state, {
refImage: action.value,
testImage: action.value,
diffImage: action.value
});
default:
return state;
}
};
export default visibilityFilter;
+18
View File
@@ -0,0 +1,18 @@
const logs = (state = {}, action) => {
switch (action.type) {
case 'OPEN_LOG_MODAL':
return Object.assign({}, state, {
visible: true,
logs: action.value
});
case 'CLOSE_LOG_MODAL':
return Object.assign({}, state, {
visible: false
});
default:
return state;
}
};
export default logs;
+89
View File
@@ -0,0 +1,89 @@
function getPosFromImgId (imgId) {
switch (imgId) {
case 'refImage':
return 100; // just passed the right border
case 'testImage':
return 0; // just passed the left border
case 'diffImage':
return 0; // just passed the left border
default:
return 50; // in the middle
}
}
function getModeFromImgId (imgId) {
switch (imgId) {
case 'refImage':
return 'SHOW_SCRUBBER_REF_IMAGE';
case 'testImage':
return 'SHOW_SCRUBBER_TEST_IMAGE';
case 'diffImage':
return 'SHOW_SCRUBBER_DIFF_IMAGE';
default:
return 'SCRUB';
}
}
const scrubber = (state = {}, action) => {
let targetImgId = '';
switch (action.type) {
case 'OPEN_SCRUBBER_MODAL':
try {
targetImgId = action.value.targetImg.id;
} catch (err) {}
return Object.assign({}, state, {
position: getPosFromImgId(targetImgId),
visible: true,
test: action.value,
testImageType: targetImgId,
scrubberModalMode: getModeFromImgId(targetImgId)
});
case 'CLOSE_SCRUBBER_MODAL':
return Object.assign({}, state, {
visible: false,
test: {}
});
case 'SHOW_SCRUBBER_TEST_IMAGE':
return Object.assign({}, state, {
position: getPosFromImgId('testImage'),
scrubberModalMode: action.type,
testImageType: 'testImage'
});
case 'SHOW_SCRUBBER_REF_IMAGE':
return Object.assign({}, state, {
position: getPosFromImgId('refImage'),
scrubberModalMode: action.type
});
case 'SHOW_SCRUBBER_DIFF_IMAGE':
return Object.assign({}, state, {
position: getPosFromImgId('diffImage'),
scrubberModalMode: action.type,
testImageType: 'diffImage'
});
case 'SHOW_SCRUBBER_DIVERGED_IMAGE':
return Object.assign({}, state, {
position: getPosFromImgId('diffImage'),
scrubberModalMode: action.type,
testImageType: 'divergedImage',
test: Object.assign({}, state.test, { divergedImage: action.value })
});
case 'SHOW_SCRUBBER':
return Object.assign({}, state, {
position: getPosFromImgId(),
scrubberModalMode: 'SCRUB',
testImageType: 'testImage'
});
default:
return state;
}
};
export default scrubber;
+10
View File
@@ -0,0 +1,10 @@
const suiteInfo = (state = {}, action) => {
switch (action.type) {
case 'SET_VISIBILITY_FILTER':
return action.filter;
default:
return state;
}
};
export default suiteInfo;
+60
View File
@@ -0,0 +1,60 @@
const tests = (state = {}, action) => {
switch (action.type) {
case 'APPROVE_TEST':
return Object.assign({}, state, {
all: state.all.map(test => {
if (test.pair && (test.pair.fileName === action.id)) {
return Object.assign({}, test, { status: 'pass' });
}
return test;
})
});
case 'FILTER_TESTS':
if (action.status !== 'all') {
return Object.assign({}, state, {
filtered: state.all.filter(e => e.status === action.status),
filterStatus: action.status
});
} else {
return Object.assign({}, state, {
filtered: state.all,
filterStatus: action.status
});
}
// @TODO: to optimize
case 'SEARCH_TESTS':
if (action.value.length > 0) {
return Object.assign({}, state, {
filtered: state.all.filter(e => {
const fileName = e.pair.fileName.toLowerCase();
const label = e.pair.label.toLowerCase();
if (state.filterStatus !== 'all') {
if (
e.status === state.filterStatus &&
(label.indexOf(action.value.toLowerCase()) !== -1 ||
fileName.indexOf(action.value.toLowerCase()) !== -1)
) {
return true;
}
} else {
if (
label.indexOf(action.value.toLowerCase()) !== -1 ||
fileName.indexOf(action.value.toLowerCase()) !== -1
) {
return true;
}
}
return false;
})
});
}
return state;
default:
return state;
}
};
export default tests;
+95
View File
@@ -0,0 +1,95 @@
import { createStore } from 'redux';
import rootReducer from './reducers';
/**
* Parses a JSON string from local storage and handles any errors.
*
* This function attempts to parse a JSON string provided in `localStorageItem`.
* If the parsing fails (typically due to corrupt or invalid JSON data),
* it logs the error, warns the user, and removes the corrupted item from
* local storage. If parsing is successful, it returns the parsed object.
* In the case of an error, it returns `false`.
*
* @param {string} localStorageItem - The JSON string to parse, typically retrieved from local storage.
* @returns {object|boolean} The parsed JSON object, or `false` if settings aren't set or parsing fails.
*/
function parseLocalStorage (localStorageItem) {
let data;
try {
data = JSON.parse(localStorageItem);
} catch (error) {
console.error(error);
console.warn('BackstopJS LocalStorage settings appear to be corrupted. Let me fix that for you.');
localStorage.removeItem('backstopjs');
data = false;
}
return data;
}
/**
* Retrieves the state from local storage, if available.
* @returns {object|boolean} The persisted state object or false if not available.
*/
const localState = localStorage.getItem('backstopjs');
const persistedState = localState
? parseLocalStorage(localState)
: false;
/**
* Default state for the Redux store.
*/
const defaultState = {
suiteInfo: {
testSuiteName: window.tests.testSuite,
idConfig: window.tests.id
},
tests: {
all: window.tests.tests,
filtered: window.tests.tests,
filterStatus: 'all'
},
scrubber: {
visible: false,
mode: 'scrub',
test: {}
},
layoutSettings: {
textInfo: false,
refImage: true,
testImage: true,
diffImage: true
}
};
/**
* Merges persisted state with default state if available, otherwise uses default state.
*/
const state = persistedState
? {
...defaultState,
...persistedState
}
: defaultState;
/**
* Creates the Redux store with root reducer, initial state, and devtools extension.
* TODO: Consider using Redux Toolkit for more efficient and modern state management.
*/
const store = createStore(
rootReducer,
state,
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
);
/**
* Subscribes to store changes to persist layout settings in local storage.
*/
store.subscribe(function () {
const layoutSettings = store.getState().layoutSettings;
const localStateItems = JSON.stringify({
layoutSettings
});
localStorage.setItem('backstopjs', localStateItems);
});
export default store;
+24
View File
@@ -0,0 +1,24 @@
export const colors = {
primaryText: '#4A4A4A',
bodyColor: '#E2E7EA',
secondaryText: '#787878',
borderGray: '#D1D9DD',
green: '#8BC34A',
red: '#F44336',
white: '#FFFFFF',
cardWhite: '#FAFAFA',
lightGray: '#EEEEEE',
medGray: '#999999'
};
export const fonts = {
latoRegular: 'latoregular',
latoBold: 'latobold',
arial: 'Arial'
};
export const shadows = {
shadow01: '0 3px 6px 0 rgba(0,0,0,0.16)',
shadow02:
'0 4px 5px 0 rgba(0,0,0,0.14), 0 1px 10px 0 rgba(0,0,0,0.12), 0 2px 4px -1px rgba(0,0,0,0.3)'
};