Skip to content

Commit

Permalink
Refactor: ExperimentCollection, ExperimentCollectionDashboard, Header…
Browse files Browse the repository at this point in the history
…, Score & Rank components (#1140)

* refactor: Remove unused 'score' variable in ExperimentCollection component

* refactor: Update import paths for Header component in ExperimentCollectionDashboard and Header files

* refactor: Move Score component & useAnimatedScore hook to their own files

* refactor: Untangle Header props

* refactor: Update Score component to use TypeScript

* refactor: Convert Rank component to use TypeScript

* test: Add unit tests for Rank component

* refactor: Update totalScore prop type to use lowercase 'number' in Header component

* story: Add Score story

* refactor: Improve Rank typing

* chore: Add scoreClass options to Score.stories.tsx

* refactor: Update Rank component to use RankPayload interface

* refactor: Update Score component to use rank class for styling

* story: Add story for "old" Score component

* refactor: Update Score and Rank components to use RankPayload interface

* refactor: Split (new) Score & Rank

* fix: Handle minor issues after rebase

* refactor: Split Rank / Score into Cup, ScoreCounter & Rank, which combines them

* fix(lint): Fix linting issues

* fix(story): Fix stories by using "block" instead of "experiment"

* story: Add Rank story

* refactor: Extract ProfileView from Profile component

* feat: Add ProfileView component and story
  • Loading branch information
drikusroor authored Jun 28, 2024
1 parent 8da17f8 commit fca85c1
Show file tree
Hide file tree
Showing 27 changed files with 735 additions and 277 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.aha__rank {
.aha__cup {
width: 150px;
height: 150px;
border-radius: 50%;
Expand Down Expand Up @@ -91,7 +91,7 @@
box-shadow: 13px 0 64px $black;
}

> h4 {
>h4 {
position: absolute;
bottom: 9px;
width: 100%;
Expand All @@ -102,7 +102,7 @@
letter-spacing: 0.1em;
}

> .cup {
>.cup {
width: 85px;
height: 85px;
background-image: $cup;
Expand All @@ -115,7 +115,7 @@
animation-timing-function: ease-in-out;
}

&.offsetCup > .cup {
&.offsetCup>.cup {
margin-top: -20px;
}
}
60 changes: 60 additions & 0 deletions frontend/src/components/Cup/Cup.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { render } from '@testing-library/react';
import Cup from './Cup';
import { describe, expect, it } from 'vitest';


describe('Cup Component', () => {
it('renders with the correct class and text', () => {
const rank = {
className: 'gold',
text: 'Gold Cup',
};

const { getByTestId, getByText } = render(<Cup {...rank} />);

const cupElement = getByTestId('cup');

// Check if the main div has the correct classes
expect(cupElement.classList.contains('aha__cup')).toBe(true);
expect(cupElement.classList.contains('gold')).toBe(true);
expect(cupElement.classList.contains('offsetCup')).toBe(true);

// Check if the h4 element contains the correct text
expect(document.body.contains(getByText('Gold Cup'))).toBe(true);
});

it('does not have offsetCup class when text is empty', () => {
const rank = {
className: 'silver',
text: '',
};

const { getByTestId } = render(<Cup {...rank} />);

const cupElement = getByTestId('cup');

// Check if the main div has the correct classes
expect(cupElement.classList.contains('aha__cup')).toBe(true);
expect(cupElement.classList.contains('silver')).toBe(true);
expect(cupElement.classList.contains('offsetCup')).toBe(false);

// Check if the h4 element contains the correct text
const cupText = getByTestId('cup-text');
expect(document.body.contains(cupText)).toBe(true);
expect(cupText.textContent).toBe('');
});

it('renders the cup div', () => {
const rank = {
className: 'bronze',
text: 'Bronze Cup',
};

const { getByTestId } = render(<Cup {...rank} />);

const cupElement = getByTestId('cup-animation');

// Check if the cup div is present
expect(document.body.contains(cupElement)).toBe(true);
});
});
22 changes: 22 additions & 0 deletions frontend/src/components/Cup/Cup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import classNames from "classnames";

export interface CupProps {
className: string | "diamond" | "platinum" | "gold" | "silver" | "bronze" | "plastic";
text: string;
}


// Rank shows a decorated representation of a rank
const Rank = ({ className, text }: CupProps) => (
<div
className={classNames("aha__cup", className, {
offsetCup: text,
})}
data-testid="cup"
>
<div className="cup" data-testid="cup-animation" />
<h4 data-testid="cup-text">{text}</h4>
</div >
);

export default Rank;
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ const ExperimentCollection = ({ match }: ExperimentCollectionProps) => {
const displayDashboard = experimentCollection?.dashboard.length;
const showConsent = experimentCollection?.consent;
const totalScore = experimentCollection?.totalScore;
const score = experimentCollection?.score;

if (experimentCollection?.theme) {
setTheme(experimentCollection.theme);
Expand Down Expand Up @@ -82,7 +81,7 @@ const ExperimentCollection = ({ match }: ExperimentCollectionProps) => {
<div className="aha__collection">
<Switch>
<Route path={URLS.experimentCollectionAbout} component={() => <ExperimentCollectionAbout content={experimentCollection?.aboutContent} slug={experimentCollection.slug} />} />
<Route path={URLS.experimentCollection} exact component={() => <ExperimentCollectionDashboard experimentCollection={experimentCollection} participantIdUrl={participantIdUrl} totalScore={totalScore} score={score} />} />
<Route path={URLS.experimentCollection} exact component={() => <ExperimentCollectionDashboard experimentCollection={experimentCollection} participantIdUrl={participantIdUrl} totalScore={totalScore} />} />
</Switch>
{experimentCollection.theme?.footer && (
<Footer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const experiment2 = getExperiment({
const collectionWithDashboard = { dashboard: [experiment1, experiment2] }

const header = {
nextExperimentButtonText: 'Next experiment',
nextBlockButtonText: 'Next experiment',
aboutButtonText: 'About us',
}
const collectionWithTheme = {
Expand Down Expand Up @@ -123,4 +123,4 @@ describe('ExperimentCollectionDashboard', () => {
);
await screen.findByText('About us');
});
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from "react";
import { Link } from "react-router-dom";

import ExperimentCollection from "@/types/ExperimentCollection";
import Header from "@/components/Header/Header";
import Header from "@/components/ExperimentCollection/Header/Header";
import Logo from "@/components/Logo/Logo";
import IBlock from "@/types/Block";

Expand All @@ -15,26 +15,29 @@ interface ExperimentCollectionDashboardProps {

export const ExperimentCollectionDashboard: React.FC<ExperimentCollectionDashboardProps> = ({ experimentCollection, participantIdUrl, totalScore }) => {

const dashboard = experimentCollection.dashboard;
const nextBlockSlug = experimentCollection.nextExperiment?.slug;

const headerProps = experimentCollection.theme?.header ? {
nextBlockSlug,
collectionSlug: experimentCollection.slug,
...experimentCollection.theme.header,
totalScore,
experimentCollectionTitle: experimentCollection.name,
experimentCollectionDescription: experimentCollection.description
const { dashboard, description, name } = experimentCollection;
const { nextExperimentButtonText, aboutButtonText } = experimentCollection.theme?.header || { nextExperimentButtonText: "", aboutButtonText: "" };

} : undefined;
const scoreDisplayConfig = experimentCollection.theme?.header?.score;
const nextBlockSlug = experimentCollection.nextExperiment?.slug;
const showHeader = experimentCollection.theme?.header;

const getExperimentHref = (slug: string) => `/${slug}${participantIdUrl ? `?participant_id=${participantIdUrl}` : ""}`;

return (
<div className="aha__dashboard">
<Logo logoClickConfirm={null} />
{headerProps && (
<Header {...headerProps}></Header>
{showHeader && (
<Header
nextBlockSlug={nextBlockSlug}
collectionSlug={experimentCollection.slug}
totalScore={totalScore}
name={name}
description={description}
scoreDisplayConfig={scoreDisplayConfig}
nextBlockButtonText={nextExperimentButtonText}
aboutButtonText={aboutButtonText}
/>
)}
{/* Experiments */}
<div role="menu" className="dashboard toontjehoger">
Expand All @@ -55,7 +58,7 @@ export const ExperimentCollectionDashboard: React.FC<ExperimentCollectionDashboa
);
}

const ImageOrPlaceholder = ({ imagePath, alt }: { imagePath: string, alt: string }) => {
const ImageOrPlaceholder = ({ imagePath, alt }: { imagePath?: string, alt: string }) => {
const imgSrc = imagePath ?? null;

return imgSrc ? <img src={imgSrc} alt={alt} /> : <div className="placeholder" />;
Expand Down
75 changes: 75 additions & 0 deletions frontend/src/components/ExperimentCollection/Header/Header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React from "react";
import { Link } from "react-router-dom";


import Social from "../../Social/Social"
import HTML from '@/components/HTML/HTML';
import { ScoreDisplayConfig } from "@/types/Theme";
import Rank from "@/components/Rank/Rank";

interface HeaderProps {
name: string;
description: string;
nextBlockSlug: string | undefined;
nextBlockButtonText: string;
collectionSlug: string;
aboutButtonText: string;
totalScore: number;
scoreDisplayConfig?: ScoreDisplayConfig;
}

export const Header: React.FC<HeaderProps> = ({
name,
description,
nextBlockSlug,
nextBlockButtonText,
aboutButtonText,
collectionSlug,
totalScore,
scoreDisplayConfig,
}) => {

// TODO: Fix this permanently and localize in and fetch content from the backend
// See also: https://github.com/Amsterdam-Music-Lab/MUSCLE/issues/1151
// Get current URL minus the query string
const currentUrl = window.location.href.split('?')[0];
const message = totalScore > 0 ? `Ha! Ik ben muzikaler dan ik dacht - heb maar liefst ${totalScore} punten! Speel mee met #ToontjeHoger` : "Ha! Speel mee met #ToontjeHoger en laat je verrassen: je bent muzikaler dat je denkt!";
const hashtags = [name ? name.replace(/ /g, '') : 'amsterdammusiclab'];

const social = {
apps: ['facebook', 'twitter'],
message,
url: currentUrl,
hashtags,
}

return (
<div className="hero">
<div className="intro">
<HTML body={description} innerClassName="" />
<nav className="actions">
{nextBlockSlug && <a className="btn btn-lg btn-primary" href={`/${nextBlockSlug}`}>{nextBlockButtonText}</a>}
{aboutButtonText && <Link className="btn btn-lg btn-outline-primary" to={`/collection/${collectionSlug}/about`}>{aboutButtonText}</Link>}
</nav>
</div>
{scoreDisplayConfig && totalScore !== 0 && (
<div className="results">
<Rank
cup={{ className: scoreDisplayConfig.scoreClass, text: '' }}
score={{ score: totalScore, label: scoreDisplayConfig.scoreLabel }}
/>
<Social
social={social}
/>
</div>
)}
{scoreDisplayConfig && totalScore === 0 && (
<h3>{scoreDisplayConfig.noScoreLabel}</h3>
)}
</div>
);
}



export default Header;
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { vi } from 'vitest';
import { vi, expect, describe, it } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { BrowserRouter, Router } from 'react-router-dom';
import { createMemoryHistory } from 'history'
Expand Down Expand Up @@ -52,8 +52,8 @@ describe('Final Component', () => {
</BrowserRouter>
);

expect(screen.queryByText(/Final Text/i)).to.exist;
expect(screen.queryByTestId('score')).to.exist;
expect(document.body.contains(screen.queryByText('Final Text'))).toBe(true);
expect(document.body.contains(screen.queryByTestId('score'))).toBe(true);
});

it('calls onNext prop when button is clicked', async () => {
Expand Down Expand Up @@ -85,8 +85,8 @@ describe('Final Component', () => {
</BrowserRouter>
);

expect(screen.queryByText('Rank')).to.not.exist;
expect(screen.queryByText('Social')).to.not.exist;
expect(document.body.contains(screen.queryByTestId('rank'))).toBe(false);
expect(document.body.contains(screen.queryByTestId('social'))).toBe(false);
});

it('navigates to profile page when profile link is clicked', async () => {
Expand All @@ -109,7 +109,7 @@ describe('Final Component', () => {

const profileLink = screen.getByTestId('profile-link');

expect(profileLink).to.exist;
expect(document.body.contains(profileLink)).toBe(true);

expect(history.location.pathname).toBe('/');

Expand Down Expand Up @@ -145,7 +145,7 @@ describe('Final Component', () => {
);

const el = screen.getByTestId('button-link');
expect(el).to.exist;
expect(document.body.contains(el)).toBe(true);
expect(el.getAttribute('href')).toBe('/redirect/aml');
});

Expand All @@ -159,7 +159,7 @@ describe('Final Component', () => {
);

const el = screen.getByTestId('button-link');
expect(el).to.exist;
expect(document.body.contains(el)).toBe(true);
expect(el.getAttribute('href')).toBe('https://example.com');
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from "react";
import React, { useEffect } from "react";
import { withRouter } from "react-router-dom";

import Rank from "../Rank/Rank";
Expand All @@ -18,39 +18,8 @@ import FinalButton from "./FinalButton";
const Final = ({ block, participant, score, final_text, action_texts, button,
onNext, history, show_participant_link, participant_id_only,
show_profile_link, social, feedback_info, points, rank, logo }) => {
const [showScore, setShowScore] = useState(0);
const session = useBoundStore((state) => state.session);

// Use a ref to prevent doing multiple increments
// when the render is skipped
const scoreValue = useRef(0);

useEffect(() => {
if (score === 0) {
return;
}

const id = setTimeout(() => {
// Score step
const scoreStep = Math.max(
1,
Math.min(10, Math.ceil(Math.abs(scoreValue.current - score) / 10))
);

// Score are equal, stop
if (score === scoreValue.current) {
return;
}
// Add / subtract score
scoreValue.current += Math.sign(score - scoreValue.current) * scoreStep;
setShowScore(scoreValue.current);
}, 50);

return () => {
clearTimeout(id);
};
}, [score, showScore]);

useEffect(() => {
finalizeSession({ session, participant });
}, [session, participant]);
Expand All @@ -59,8 +28,7 @@ const Final = ({ block, participant, score, final_text, action_texts, button,
<div className="aha__final d-flex flex-column justify-content-center">
{rank && (
<div className="text-center">
<Rank rank={rank} />
<h1 className="total-score title" data-testid="score">{showScore} {points}</h1>
<Rank cup={{ className: rank.class, text: rank.text }} score={{ score, label: points }} />
</div>
)}
<div className="text-center">
Expand Down
Loading

0 comments on commit fca85c1

Please sign in to comment.